Thinking House Lab
EN

Language

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

Power Flow Logic · Episode 1 · Level 3

Rebuild: the battery account in code

The rules from episode 1 as small, readable Python: an account that knows what each kilowatt hour in the battery cost, and a rule that decides between heat pump and gas with the price of the right source. Every example from the videos is a test you can run.

RebuildFree14 min read

The problem in one paragraph

A home battery stores energy from two sources: the sun, which costs nothing extra once the panels are paid for, and the grid, which costs whatever the tariff says at that moment. On a dynamic tariff, that changes every quarter hour. A system that values the battery with the wrong number makes wrong decisions. In episode 1, a battery 99 % full of almost free solar power did not stop the system from switching off the heat pump, because it looked at the grid price of the moment (98 ct) instead of the price of what was actually in the battery. The fix is bookkeeping: treat the battery like an account that records what went in and what it cost, and for every decision use the price of the source the electricity would really come from.

Key facts

The battery account at a glance
QuestionAnswer
What it isAn account with two numbers: the energy in the battery (kWh) and what exactly that energy cost (€). The price per kWh is cost ÷ energy.
When it booksOn every charge: solar power at 0 ct, grid power at the price of that moment. On every discharge: energy and cost both drop, at the average price. A running system books about once a minute from power readings.
What it never booksA charge whose source is unknown. Battery wear, which is a separate number and never part of the price. Charging and discharging losses are not modelled.
Why it mattersEvery decision that uses battery power needs its real price: heat pump or gas right now, and later, whether to charge from the grid.
What it influencesThe heat price decision: price of the source ÷ COP against gas price ÷ boiler efficiency. Gas only wins above the tipping point, gas heat price × COP.
InputsBattery power, solar power, grid power and the grid price. Each of them may be unknown, and unknown is never treated as zero.
OutputsThe price per kWh in the battery (or no price when it is empty), the source of the next kWh (sun, battery or grid), and whether the heat pump may run.

The maths

The account is a weighted average. Every kilowatt hour brings the price it was bought at, and the price of the whole content follows from two sums:

price = cost ÷ energy = Σ (kWh × price paid) ÷ Σ kWh

Charging mixes the new energy into what is already there. Taking energy out removes it at the current average, so the price stays exactly where it was:

after a charge: new price = (energy × old price + kWh in × their price) ÷ (energy + kWh in)

after a discharge of x kWh: energy − x, cost − x × price → price unchanged

The account through episode 1, step by step
  1. no price

    0 kWh (sun 0 kWh, grid 0 kWh)

    Empty battery

  2. 0 ctper kWh

    10 kWh (sun 10 kWh, grid 0 kWh)

    +10 kWh sun

  3. 10 ctper kWh

    15 kWh (sun 10 kWh, grid 5 kWh)

    +5 kWh grid at 30 ct

  4. 10 ctper kWh

    5 kWh (sun 3.3 kWh, grid 1.7 kWh)

    −10 kWh to the house

  5. 20 ctper kWh

    10 kWh (sun 3.3 kWh, grid 6.7 kWh)

    +5 kWh grid at 30 ct

  • Sun · 0 ct
  • Grid · 30 ct

Example values from episode 1. The bars show what is in the battery, by origin; the number above each bar is the account price. The website calculates them with the same rules as the code package.

Look at the fourth step: the house took out two thirds of the energy, and the price did not move. Only the fifth step changes it, because new energy comes in at a different price. The same effect explains the almost empty battery from level 2: 9 kWh of sun plus 1 kWh from the grid at 40 ct gives 4 ct per kWh, 1 kWh of sun plus 9 kWh at 40 ct gives 36 ct. What is already inside decides.

For heat, compare heat with heat. A heat pump turns one kilowatt hour of electricity into COP kilowatt hours of heat; a gas boiler turns one kilowatt hour of gas into slightly less than one kilowatt hour of heat:

heat pump heat = price of the source ÷ COP · gas heat = gas price ÷ efficiency

tipping point = gas heat price × COP

The numbers from episode 1 (COP 3, gas heat about 10 ct per kWh of heat)
CaseElectricityHeat pump heatResult
Battery at 10 ct10 ct3.3 ctheat pump
Battery refilled from the grid at 42 ct42 ct14 ctgas
Tipping point at COP 330 ct10 cttie: heat pump runs
Tipping point at COP 5.9 (mild weather)59 ct10 cttie: heat pump runs

Gas only wins when the electricity really costs more than the tipping point.

The code, explained

The package has two files in src/akkukonto/. The excerpts below are taken straight from the download, so they match it line for line. Standard library only, no other packages.

The account: BatteryAccountsrc/akkukonto/konto.py · lines 38–93
@dataclass
class BatteryAccount:
    """Bookkeeping for the energy in a battery, in kWh and in euros."""

    energy_kwh: float = 0.0
    """Stock: energy booked into the battery right now (goes up and down)."""

    cost_eur: float = 0.0
    """Stock: what that energy cost (goes up and down with it)."""

    solar_in_kwh: float = 0.0
    """Counter: everything ever charged from the sun (never goes down)."""

    grid_in_kwh: float = 0.0
    """Counter: everything ever charged from the grid (never goes down)."""

    def charge(self, kwh: float, price_eur_kwh: float) -> None:
        """Book kwh at price_eur_kwh. Every kilowatt hour brings its price."""
        if kwh <= 0:
            return
        self.energy_kwh += kwh
        self.cost_eur += kwh * price_eur_kwh

    def charge_solar(self, kwh: float, price_eur_kwh: float = 0.0) -> None:
        """Charge from the sun. The panels are paid for: price 0 by default."""
        if kwh <= 0:
            return
        self.charge(kwh, price_eur_kwh)
        self.solar_in_kwh += kwh

    def charge_grid(self, kwh: float, price_eur_kwh: float) -> None:
        """Charge from the grid at the price that applied when it came in."""
        if kwh <= 0:
            return
        self.charge(kwh, price_eur_kwh)
        self.grid_in_kwh += kwh

    def discharge(self, kwh: float) -> float:
        """Take kwh out at the average price; returns the kWh actually booked.

        The price stays exactly where it was: only the amount changes.
        An empty account books nothing (there is no price to take out at).
        """
        price = self.price()
        if kwh <= 0 or price is None:
            return 0.0
        taken = min(kwh, self.energy_kwh)
        self.energy_kwh -= taken
        self.cost_eur = self.energy_kwh * price
        return taken

    def price(self) -> float | None:
        """Price per kWh in EUR, or None if the account is empty."""
        if self.energy_kwh <= EMPTY_KWH:
            return None
        return self.cost_eur / self.energy_kwh
  • Two stocks (energy_kwh, cost_eur) go up and down with the battery. Two counters (solar_in_kwh, grid_in_kwh) only grow and give the solar share over time.
  • charge() adds the kilowatt hours and their cost. That is all the mixing there is: the weighted average falls out of the division in price().
  • discharge() reads the price first, lowers the energy and sets the cost to energy × that same price. The price cannot move, by construction.
  • price() returns None up to 0.01 kWh. An empty battery has no price, and None forces every caller to handle that case instead of quietly using zero.
Booking from power readings: MeterBookkeeper.book_interval()src/akkukonto/konto.py · lines 167–208
def book_interval(self, *, hours: float, battery_w: float | None,
                  solar_w: float | None, grid_import_w: float | None,
                  grid_price_eur_kwh: float | None) -> Booking:
    """Book one interval of length hours. Returns what was booked."""
    if hours <= 0:
        return Booking("idle", 0.0, reason="no time has passed")
    if battery_w is None:
        return Booking("skipped", 0.0, reason="battery power unknown")

    kwh = self._smooth(battery_w) * hours / 1000.0

    if kwh < 0:
        taken = self.account.discharge(-kwh)
        return Booking("discharge", -taken,
                       reason="price unchanged" if taken else "account empty")
    if kwh == 0:
        return Booking("idle", 0.0, reason="battery at rest")

    # Charging: where does this energy come from?
    if solar_w is None or grid_import_w is None:
        return Booking("skipped", 0.0,
                       reason="source unknown, nothing booked")
    solar = max(solar_w, 0.0)
    grid = max(grid_import_w, 0.0)
    total = solar + grid
    # Split in proportion to solar power and grid import right now.
    # Nothing measured on either side: counted as grid (the expensive,
    # careful choice).
    solar_share = solar / total if total > 0 else 0.0
    solar_kwh = kwh * solar_share
    grid_kwh = kwh - solar_kwh

    price = None
    if grid_kwh > 0:
        if grid_price_eur_kwh is None:
            return Booking("skipped", 0.0, reason="grid price unknown")
        price = min(grid_price_eur_kwh, self.settings.max_grid_price_eur_kwh)
        self.account.charge_grid(grid_kwh, price)
    if solar_kwh > 0:
        self.account.charge_solar(solar_kwh, self.settings.solar_price_eur_kwh)
    return Booking("charge", kwh, solar_kwh, grid_kwh, price,
                   reason="booked by source")
  • A running system never sees kWh from the sun, only power readings. This method turns one interval of readings into a booking.
  • The battery power is smoothed first (ema_alpha 0.3, so 30 % new reading). Without it, sensor flicker at night slowly books energy that never came in.
  • Discharging needs no source, because it never changes the price.
  • Charging needs the solar and the grid reading. If either is unknown, nothing is booked: a gap in the books does less harm than a wrong booking, which lingers for days.
  • A charge is split in proportion to solar power and grid import at that moment. If neither side measures anything, the charge counts as grid, the careful choice. Grid prices above 1 € per kWh are treated as a data error and capped.
Where does the next kWh come from? source_price()src/akkukonto/quellenpreis.py · lines 79–103
def source_price(*, surplus_w: float | None, soc_pct: float | None,
                 battery_may_discharge: bool, account_price_eur_kwh: float | None,
                 grid_price_eur_kwh: float,
                 settings: SourceSettings | None = None) -> SourcePrice:
    """Where would the next kWh come from, and what does it cost?"""
    s = settings if settings is not None else SourceSettings()

    if surplus_w is not None and surplus_w >= s.min_surplus_w:
        return SourcePrice(0.0, SOLAR, "solar surplus, would otherwise be exported")

    soc_ok = soc_pct is not None and soc_pct > s.battery_floor_pct
    if soc_ok and battery_may_discharge and account_price_eur_kwh is not None:
        return SourcePrice(account_price_eur_kwh, BATTERY,
                           "battery above its floor delivers the kWh")

    if soc_pct is None:
        why = "battery level unknown"
    elif not battery_may_discharge:
        why = "battery may not discharge"
    elif not soc_ok:
        why = "battery at or below its floor"
    else:
        # An unknown account price is not a price of zero: count as grid.
        why = "account price unknown"
    return SourcePrice(grid_price_eur_kwh, GRID, f"{why}: the kWh comes from the grid")
  • Three answers, checked in order: a solar surplus gives price 0; the battery gives the account price if it is above its floor, may discharge and knows its price; otherwise the grid price of this moment applies.
  • The floor (battery_floor_pct) only changes which price is used for the decision, never the battery itself.
  • An unknown account price counts as grid. Unknown is not zero.
Heat pump or gas: decide_heat_source()src/akkukonto/quellenpreis.py · lines 146–163
def decide_heat_source(*, source: SourcePrice, cop: float | None,
                       gas_heat_eur_kwh: float | None,
                       fallback_blocked: bool = False) -> HeatDecision:
    """Block the heat pump only if its heat costs more than gas heat."""
    if source.source == SOLAR:
        return HeatDecision(False, source, 0.0, gas_heat_eur_kwh, True,
                            "solar surplus never blocks the heat pump")

    hp_heat = source.eur_kwh / cop if cop is not None and cop > 0 else None
    if hp_heat is None or gas_heat_eur_kwh is None:
        missing = "COP" if hp_heat is None else "gas heat price"
        return HeatDecision(fallback_blocked, source, hp_heat, gas_heat_eur_kwh, False,
                            f"{missing} missing: fallback decides")

    blocked = (hp_heat - gas_heat_eur_kwh) > TIE_TOLERANCE_EUR_KWH
    verdict = "gas is cheaper" if blocked else "heat pump is cheaper or equal"
    return HeatDecision(blocked, source, hp_heat, gas_heat_eur_kwh, True,
                        f"{verdict} ({source.source})")
  • A solar surplus never blocks the heat pump.
  • Without a COP or a gas price there is no comparison. Nothing is made up; your previous rule decides (fallback_blocked).
  • The heat pump is blocked only if its heat costs more than gas heat by more than 0.1 ct. On a tie, it runs.

Run it and check the tests

You need Python 3.10 or newer, nothing else. Unpack the zip and open a terminal in the folder akkukonto:

Examples and tests
python examples/episode1_walkthrough.py     # episode 1, step by step
python examples/meter_day.py            # a made-up day, booked from power readings
python -m pip install pytest            # only needed for the tests
python -m pytest                        # ends with: 49 passed

The walkthrough prints episode 1 with the numbers from the video:

Output of episode1_walkthrough.py
Start: empty battery                     0.0 kWh   0.00 EUR  -> no price per kWh
Sun puts in 10 kWh (0 ct)               10.0 kWh   0.00 EUR  -> 0.00 ct per kWh
Grid adds 5 kWh at 30 ct                15.0 kWh   1.50 EUR  -> 10.00 ct per kWh
House takes out 10 kWh                   5.0 kWh   0.50 EUR  -> 10.00 ct per kWh
Grid adds 5 kWh at 30 ct again          10.0 kWh   2.00 EUR  -> 20.00 ct per kWh

Winter, COP 3, gas heat 10.00 ct per kWh of heat
  Battery at 10 ct                     heat 3.33 ct vs gas 10.00 ct -> heat pump
  Battery refilled from grid at 42 ct  heat 14.00 ct vs gas 10.00 ct -> gas

Tipping point winter (COP 3.0): electricity above 30.00 ct makes gas cheaper
Tipping point mild weather (COP 5.9): electricity above 59.00 ct makes gas cheaper

The 49 tests cover every example from both videos and the edge cases: empty account, unknown readings, capped prices, ties, implausible COP values. Five of them you can check by hand:

Five cases to check by hand
#CaseExpected
110 kWh sun + 5 kWh grid at 30 ct15 kWh for 1.50 € → 10 ct
2then the house takes out 10 kWh5 kWh for 0.50 € → still 10 ct
3then 5 kWh more from the grid at 30 ct10 kWh for 2.00 € → 20 ct
4COP 3, battery at 10 ct, gas heat 10 ctheat 3.3 ct → heat pump
5battery refilled at 42 ct, COP 3heat 14 ct, more than 10 ct → gas

All five are tests in the package, named after the scene they check.

Case 1 as a testtests/test_konto.py · lines 10–16
def test_episode1_sun_and_grid_give_ten_cents():
    acc = BatteryAccount()
    acc.charge_solar(10)            # sun: 10 kWh for nothing
    acc.charge_grid(5, 0.30)        # grid: 5 kWh at 30 ct = 1.50 EUR
    assert acc.energy_kwh == pytest.approx(15)
    assert acc.cost_eur == pytest.approx(1.50)
    assert acc.price() == pytest.approx(0.10)
  • 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, with every setting, is in VARIABLEN.md in the package.

Battery account and booking from readings
NameUnitMeaningTypical rangeComes from
energy_kwhkWhEnergy booked into the battery right now0 … usable size of your batterycharges minus discharges
cost_eur€What exactly that energy cost0 … a few € per 10 kWhkWh × price of each charge
price()€/kWhCost ÷ energy; None when the account is emptyalmost 0 (summer) … about 0.40 (winter, from the grid)calculated
solar_share()0 … 1Share of everything charged that came from the sun0.5 … 1.0 with solar panelsthe two counters
battery_wWBattery power: + charging, − discharginga few hundred … several thousandbattery or inverter reading
solar_wWSolar power right now0 … peak power of your panelssolar inverter reading
grid_import_wWGrid power: + from the grid, − into the griddepends on house and tariffgrid meter
grid_price_eur_kwh€/kWhPrice paid in this interval, incl. fees and taxesabout 0.15 … 0.45 on a dynamic tariffyour tariff, per quarter hour
ema_alpha–Smoothing: share of the newest battery reading0.3 for readings once a minutesetting
max_grid_price_eur_kwh€/kWhSanity limit; higher grid prices are capped1.0setting
Price of the source and heat decision
NameUnitMeaningTypical rangeComes from
surplus_wWSolar power left over after what the house needs0 … peak power of your panelsenergy manager or grid meter
soc_pct%Battery state of charge0 … 100battery management
battery_floor_pct%At or below this level, the next kWh counts as grid powerexample 55setting
cop–Heat per unit of electricity (coefficient of performance)about 2.5 at −6 °C … above 6 in mild weatherchoose_cop()
gas_price_eur_kwh€/kWhGas price per kWh of gasabout 0.08 … 0.12gas tariff
efficiency–Boiler efficiency, held between 0.5 and 0.990.90 … 0.98data sheet or own measurement
gas_heat_eur_kwh€/kWh heatGas price ÷ efficiency (+ optional extra)about 0.10gas_heat_price()
heat_pump_heat_eur_kwh€/kWh heatPrice of the source ÷ COP0 … 0.15calculated

Adapting it to your house

  • You are paid for feeding solar into the grid? Then a solar kWh in the battery costs what you could have sold it for: set MeterSettings(solar_price_eur_kwh=…) to your feed-in tariff.
  • Quarter-hour prices: pass the price of the current quarter hour to every book_interval() call. The account does the rest.
  • Losses: not modelled. One simple way: book the energy measured at the battery and divide the grid price by the charging efficiency.
  • Keep the account across restarts: it only lives in memory. Save the four numbers (energy_kwh, cost_eur, solar_in_kwh, grid_in_kwh) and pass them back when you create the account.
  • Check the age of every reading before you pass it in. A reading frozen at its last value looks like a real one; a frozen solar reading at night would look like free charging.
  • Another heat source or air conditioning: the same comparison works, price of the source ÷ COP against the price of the other source.

Working with an AI assistant? Give it the package, VARIABLEN.md and the names of your own readings, and ask it for the adapter between your readings and book_interval(). The tests tell you whether the logic still holds afterwards.

Download

Code package: battery account

Source code, two examples, 49 tests, the variable list and notes on its origin. Python standard library only.

File
akkukonto-1.0.0.zip
Version
1.0.0 · 6 October 2026
Size
22 KB · 13 files
Licence
MIT · see the note in the imprint
Price
Free
SHA-256
dba504842c478434a95c46e7353d2054283b2294b419a5a1bf87686207b323a0

Download zip (22 KB)Checksum file

To check the download, compare the SHA-256 checksum: certutil -hashfile akkukonto-1.0.0.zip SHA256 on Windows, shasum -a 256 akkukonto-1.0.0.zip on macOS, sha256sum akkukonto-1.0.0.zip on Linux.

Version and origin

Sources

Links checked on 6 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