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
| Question | Answer |
|---|---|
| What it is | An 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 books | On 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 books | A 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 matters | Every 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 influences | The heat price decision: price of the source ÷ COP against gas price ÷ boiler efficiency. Gas only wins above the tipping point, gas heat price × COP. |
| Inputs | Battery power, solar power, grid power and the grid price. Each of them may be unknown, and unknown is never treated as zero. |
| Outputs | The 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
no price
0 kWh (sun 0 kWh, grid 0 kWh)
Empty battery
0 ctper kWh
10 kWh (sun 10 kWh, grid 0 kWh)
+10 kWh sun
10 ctper kWh
15 kWh (sun 10 kWh, grid 5 kWh)
+5 kWh grid at 30 ct
10 ctper kWh
5 kWh (sun 3.3 kWh, grid 1.7 kWh)
−10 kWh to the house
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
| Case | Electricity | Heat pump heat | Result |
|---|---|---|---|
| Battery at 10 ct | 10 ct | 3.3 ct | heat pump |
| Battery refilled from the grid at 42 ct | 42 ct | 14 ct | gas |
| Tipping point at COP 3 | 30 ct | 10 ct | tie: heat pump runs |
| Tipping point at COP 5.9 (mild weather) | 59 ct | 10 ct | tie: 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.
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 inprice().discharge()reads the price first, lowers the energy and sets the cost to energy × that same price. The price cannot move, by construction.price()returnsNoneup to 0.01 kWh. An empty battery has no price, andNoneforces every caller to handle that case instead of quietly using zero.
MeterBookkeeper.book_interval()src/akkukonto/konto.py · lines 167–208def 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_alpha0.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.
source_price()src/akkukonto/quellenpreis.py · lines 79–103def 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.
decide_heat_source()src/akkukonto/quellenpreis.py · lines 146–163def 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:
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 passedThe walkthrough prints episode 1 with the numbers from the video:
episode1_walkthrough.pyStart: 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 cheaperThe 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:
| # | Case | Expected |
|---|---|---|
| 1 | 10 kWh sun + 5 kWh grid at 30 ct | 15 kWh for 1.50 € → 10 ct |
| 2 | then the house takes out 10 kWh | 5 kWh for 0.50 € → still 10 ct |
| 3 | then 5 kWh more from the grid at 30 ct | 10 kWh for 2.00 € → 20 ct |
| 4 | COP 3, battery at 10 ct, gas heat 10 ct | heat 3.3 ct → heat pump |
| 5 | battery refilled at 42 ct, COP 3 | heat 14 ct, more than 10 ct → gas |
All five are tests in the package, named after the scene they check.
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.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, with every setting, is in VARIABLEN.md in the package.
| Name | Unit | Meaning | Typical range | Comes from |
|---|---|---|---|---|
energy_kwh | kWh | Energy booked into the battery right now | 0 … usable size of your battery | charges minus discharges |
cost_eur | € | What exactly that energy cost | 0 … a few € per 10 kWh | kWh × price of each charge |
price() | €/kWh | Cost ÷ energy; None when the account is empty | almost 0 (summer) … about 0.40 (winter, from the grid) | calculated |
solar_share() | 0 … 1 | Share of everything charged that came from the sun | 0.5 … 1.0 with solar panels | the two counters |
battery_w | W | Battery power: + charging, − discharging | a few hundred … several thousand | battery or inverter reading |
solar_w | W | Solar power right now | 0 … peak power of your panels | solar inverter reading |
grid_import_w | W | Grid power: + from the grid, − into the grid | depends on house and tariff | grid meter |
grid_price_eur_kwh | €/kWh | Price paid in this interval, incl. fees and taxes | about 0.15 … 0.45 on a dynamic tariff | your tariff, per quarter hour |
ema_alpha | – | Smoothing: share of the newest battery reading | 0.3 for readings once a minute | setting |
max_grid_price_eur_kwh | €/kWh | Sanity limit; higher grid prices are capped | 1.0 | setting |
| Name | Unit | Meaning | Typical range | Comes from |
|---|---|---|---|---|
surplus_w | W | Solar power left over after what the house needs | 0 … peak power of your panels | energy manager or grid meter |
soc_pct | % | Battery state of charge | 0 … 100 | battery management |
battery_floor_pct | % | At or below this level, the next kWh counts as grid power | example 55 | setting |
cop | – | Heat per unit of electricity (coefficient of performance) | about 2.5 at −6 °C … above 6 in mild weather | choose_cop() |
gas_price_eur_kwh | €/kWh | Gas price per kWh of gas | about 0.08 … 0.12 | gas tariff |
efficiency | – | Boiler efficiency, held between 0.5 and 0.99 | 0.90 … 0.98 | data sheet or own measurement |
gas_heat_eur_kwh | €/kWh heat | Gas price ÷ efficiency (+ optional extra) | about 0.10 | gas_heat_price() |
heat_pump_heat_eur_kwh | €/kWh heat | Price of the source ÷ COP | 0 … 0.15 | calculated |
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
- Weighted arithmetic mean (Wikipedia)(external)
- Exponential smoothing (Wikipedia)(external)
- Coefficient of performance (Wikipedia)(external)
- SMARD – electricity market data for Germany, incl. wholesale prices (Federal Network Agency)(external)
- pytest documentation(external)
- The MIT License (Open Source Initiative)(external)
- EnergyPilot 3 source code, version 3.96.1: read, simplified, not published (own code)
Links checked on 6 October 2026.