Das Problem in einem Absatz
Ein Heimspeicher lädt aus zwei Quellen: aus der Sonne, die nichts mehr extra kostet, wenn die Module bezahlt sind, und aus dem Netz, das so viel kostet, wie der Tarif in diesem Moment verlangt. Bei einem dynamischen Tarif ändert sich das jede Viertelstunde. Bewertet ein System den Akku mit der falschen Zahl, entscheidet es falsch. In Folge 1 war der Akku zu 99 % mit fast kostenlosem Sonnenstrom gefüllt, und trotzdem schaltete das System die Wärmepumpe ab: Es schaute auf den Netzpreis des Moments (98 ct) statt auf den Preis dessen, was wirklich im Akku war. Die Lösung ist Buchhaltung: den Akku wie ein Konto führen, das festhält, was hineinging und was es gekostet hat, und für jede Entscheidung den Preis der Quelle nehmen, aus der der Strom wirklich käme.
Kernfakten
| Frage | Antwort |
|---|---|
| Was es ist | Ein Konto mit zwei Zahlen: der Energie im Akku (kWh) und dem, was genau diese Energie gekostet hat (€). Der Preis je kWh ist Kosten ÷ Energie. |
| Wann es bucht | Bei jeder Ladung: Sonnenstrom zu 0 ct, Netzstrom zum Preis dieses Moments. Bei jeder Entnahme: Energie und Kosten sinken, zum Durchschnittspreis. Ein laufendes System bucht etwa einmal pro Minute aus Leistungswerten. |
| Was es nie bucht | Eine Ladung, deren Quelle unbekannt ist. Akkuverschleiß: Er ist eine eigene Zahl und nie Teil des Preises. Lade- und Entladeverluste sind nicht abgebildet. |
| Warum es zählt | Jede Entscheidung, die Akkustrom nutzt, braucht dessen echten Preis: jetzt Wärmepumpe oder Gas, später auch, ob aus dem Netz geladen wird. |
| Worauf es wirkt | Auf die Wärmepreis-Entscheidung: Preis der Quelle ÷ COP gegen Gaspreis ÷ Kesselwirkungsgrad. Gas gewinnt erst oberhalb des Kipppunkts, Gas-Wärmepreis × COP. |
| Eingänge | Akkuleistung, PV-Leistung, Netzleistung und Netzpreis. Jeder Wert darf unbekannt sein, und unbekannt wird nie wie null behandelt. |
| Ausgänge | Der Preis je kWh im Akku (oder kein Preis, wenn er leer ist), die Quelle der nächsten kWh (Sonne, Akku oder Netz) und ob die Wärmepumpe laufen darf. |
Die Rechnung
Das Konto ist ein gewichteter Mittelwert. Jede Kilowattstunde bringt den Preis mit, zu dem sie gekauft wurde, und der Preis des ganzen Inhalts ergibt sich aus zwei Summen:
Preis = Kosten ÷ Energie = Σ (kWh × bezahlter Preis) ÷ Σ kWh
Laden mischt die neue Energie unter das, was schon drin ist. Entnehmen nimmt sie zum aktuellen Durchschnitt heraus, deshalb bleibt der Preis genau, wo er war:
nach einer Ladung: neuer Preis = (Energie × alter Preis + geladene kWh × ihr Preis) ÷ (Energie + geladene kWh)
nach einer Entnahme von x kWh: Energie − x, Kosten − x × Preis → Preis unverändert
kein Preis
0 kWh (Sonne 0 kWh, Netz 0 kWh)
Leerer Akku
0 ctje kWh
10 kWh (Sonne 10 kWh, Netz 0 kWh)
+10 kWh Sonne
10 ctje kWh
15 kWh (Sonne 10 kWh, Netz 5 kWh)
+5 kWh Netz zu 30 ct
10 ctje kWh
5 kWh (Sonne 3,3 kWh, Netz 1,7 kWh)
−10 kWh ins Haus
20 ctje kWh
10 kWh (Sonne 3,3 kWh, Netz 6,7 kWh)
+5 kWh Netz zu 30 ct
- Sonne · 0 ct
- Netz · 30 ct
Beispielwerte aus Folge 1. Die Balken zeigen, was im Akku ist, nach Herkunft; die Zahl über jedem Balken ist der Kontopreis. Die Website rechnet sie mit denselben Regeln wie das Code-Paket.
Achte auf den vierten Schritt: Das Haus hat zwei Drittel der Energie entnommen, und der Preis hat sich nicht bewegt. Erst der fünfte Schritt ändert ihn, weil neue Energie zu einem anderen Preis hinzukommt. Derselbe Effekt erklärt den fast leeren Akku aus Stufe 2: 9 kWh Sonne plus 1 kWh aus dem Netz zu 40 ct ergeben 4 ct je kWh, 1 kWh Sonne plus 9 kWh zu 40 ct ergeben 36 ct. Was schon drin ist, entscheidet.
Bei Wärme wird Wärme mit Wärme verglichen. Eine Wärmepumpe macht aus einer Kilowattstunde Strom COP Kilowattstunden Wärme; ein Gaskessel macht aus einer Kilowattstunde Gas etwas weniger als eine Kilowattstunde Wärme:
Wärme aus der Wärmepumpe = Preis der Quelle ÷ COP · Gaswärme = Gaspreis ÷ Wirkungsgrad
Kipppunkt = Gas-Wärmepreis × COP
| Fall | Strom | Wärme aus der Wärmepumpe | Ergebnis |
|---|---|---|---|
| Akku zu 10 ct | 10 ct | 3,3 ct | Wärmepumpe |
| Akku aus dem Netz nachgeladen zu 42 ct | 42 ct | 14 ct | Gas |
| Kipppunkt bei COP 3 | 30 ct | 10 ct | Gleichstand: Wärmepumpe läuft |
| Kipppunkt bei COP 5,9 (mildes Wetter) | 59 ct | 10 ct | Gleichstand: Wärmepumpe läuft |
Gas gewinnt nur, wenn der Strom wirklich mehr kostet als der Kipppunkt.
Der Code, erklärt
Das Paket hat zwei Dateien in src/akkukonto/. Die Ausschnitte unten stammen direkt aus dem Download und stimmen Zeile für Zeile mit ihm überein. Code und Kommentare sind englisch. Nur Standardbibliothek, keine weiteren Pakete.
BatteryAccountsrc/akkukonto/konto.py · Zeilen 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- Zwei Bestände (
energy_kwh,cost_eur) steigen und fallen mit dem Akku. Zwei Zähler (solar_in_kwh,grid_in_kwh) wachsen nur und liefern den PV-Anteil über die Zeit. charge()addiert die Kilowattstunden und ihre Kosten. Mehr Mischen gibt es nicht: Der gewichtete Mittelwert ergibt sich aus der Division inprice().discharge()liest zuerst den Preis, senkt die Energie und setzt die Kosten auf Energie × genau diesen Preis. Der Preis kann sich dabei gar nicht bewegen.price()liefert bis 0,01 kWhNone. Ein leerer Akku hat keinen Preis, undNonezwingt jeden Aufrufer, diesen Fall zu behandeln, statt still mit null weiterzurechnen.
MeterBookkeeper.book_interval()src/akkukonto/konto.py · Zeilen 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")- Ein laufendes System sieht nie kWh aus der Sonne, nur Leistungswerte. Diese Methode macht aus den Messwerten eines Intervalls eine Buchung.
- Zuerst wird die Akkuleistung geglättet (
ema_alpha0,3, also 30 % neuer Messwert). Ohne Glättung bucht das Flackern der Sensoren nachts langsam Energie, die nie hineinkam. - Entladen braucht keine Quelle, denn es ändert den Preis nie.
- Laden braucht den PV- und den Netz-Messwert. Fehlt einer, wird nichts gebucht: Eine Lücke in der Buchhaltung schadet weniger als eine falsche Buchung, die tagelang nachwirkt.
- Eine Ladung wird im Verhältnis von PV-Leistung und Netzbezug in diesem Moment aufgeteilt. Misst keine Seite etwas, zählt die Ladung als Netz, die vorsichtige Wahl. Netzpreise über 1 € je kWh gelten als Datenfehler und werden gekappt.
source_price()src/akkukonto/quellenpreis.py · Zeilen 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")- Drei Antworten, in dieser Reihenfolge geprüft: PV-Überschuss ergibt Preis 0; der Akku liefert den Kontopreis, wenn er über seinem Boden liegt, entladen darf und seinen Preis kennt; sonst gilt der Netzpreis dieses Moments.
- Der Boden (
battery_floor_pct) ändert nur, welcher Preis in die Entscheidung geht, nie den Akku selbst. - Ein unbekannter Kontopreis zählt als Netz. Unbekannt ist nicht null.
decide_heat_source()src/akkukonto/quellenpreis.py · Zeilen 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})")- PV-Überschuss sperrt die Wärmepumpe nie.
- Ohne COP oder Gaspreis gibt es keinen Vergleich. Es wird nichts erfunden; deine bisherige Regel entscheidet (
fallback_blocked). - Die Wärmepumpe wird nur gesperrt, wenn ihre Wärme mehr als 0,1 ct teurer ist als Gaswärme. Bei Gleichstand läuft sie.
Ausführen und Tests prüfen
Du brauchst Python 3.10 oder neuer, sonst nichts. Zip entpacken und im Ordner akkukonto ein Terminal öffnen:
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 passedDer Rundgang gibt Folge 1 mit den Zahlen aus dem Video aus:
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 cheaperDie 49 Tests decken jedes Beispiel aus beiden Videos ab und dazu die Randfälle: leeres Konto, unbekannte Messwerte, gekappte Preise, Gleichstand, unplausible COP-Werte. Fünf davon kannst du von Hand nachrechnen:
| Nr. | Fall | Erwartet |
|---|---|---|
| 1 | 10 kWh Sonne + 5 kWh Netz zu 30 ct | 15 kWh für 1,50 € → 10 ct |
| 2 | danach entnimmt das Haus 10 kWh | 5 kWh für 0,50 € → weiterhin 10 ct |
| 3 | danach noch 5 kWh aus dem Netz zu 30 ct | 10 kWh für 2,00 € → 20 ct |
| 4 | COP 3, Akku zu 10 ct, Gaswärme 10 ct | Wärme 3,3 ct → Wärmepumpe |
| 5 | Akku zu 42 ct nachgeladen, COP 3 | Wärme 14 ct, mehr als 10 ct → Gas |
Alle fünf sind Tests im Paket, benannt nach der Szene, die sie prüfen.
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.approxvergleicht Kommazahlen mit einer winzigen Toleranz, damit Rundung in der letzten Stelle keinen Test scheitern lässt.
Variablen
Die wichtigsten Namen mit Einheit und typischem Bereich. Alle Bereiche sind Beispielbereiche, keine Werte einer echten Anlage. Die vollständige Liste mit allen Einstellungen steht in VARIABLEN.md im Paket.
| Name | Einheit | Bedeutung | Typischer Bereich | Herkunft |
|---|---|---|---|---|
energy_kwh | kWh | Energie, die gerade im Konto steht | 0 … nutzbare Größe deines Akkus | Ladungen minus Entnahmen |
cost_eur | € | Was genau diese Energie gekostet hat | 0 … wenige € je 10 kWh | kWh × Preis jeder Ladung |
price() | €/kWh | Kosten ÷ Energie; None, wenn das Konto leer ist | fast 0 (Sommer) … etwa 0,40 (Winter, aus dem Netz) | berechnet |
solar_share() | 0 … 1 | Anteil der Sonne an allem, was geladen wurde | 0,5 … 1,0 mit PV-Anlage | die beiden Zähler |
battery_w | W | Akkuleistung: + Laden, − Entladen | einige Hundert … mehrere Tausend | Messwert von Akku oder Wechselrichter |
solar_w | W | PV-Leistung in diesem Moment | 0 … Spitzenleistung deiner Module | Messwert des PV-Wechselrichters |
grid_import_w | W | Netzleistung: + Bezug, − Einspeisung | hängt von Haus und Tarif ab | Netzzähler |
grid_price_eur_kwh | €/kWh | In diesem Intervall bezahlter Preis, inkl. Umlagen und Steuern | etwa 0,15 … 0,45 bei dynamischem Tarif | dein Tarif, je Viertelstunde |
ema_alpha | – | Glättung: Anteil des neuesten Akku-Messwerts | 0,3 bei Messwerten einmal pro Minute | Einstellung |
max_grid_price_eur_kwh | €/kWh | Plausibilitätsgrenze; höhere Netzpreise werden gekappt | 1,0 | Einstellung |
| Name | Einheit | Bedeutung | Typischer Bereich | Herkunft |
|---|---|---|---|---|
surplus_w | W | PV-Leistung, die über den Bedarf des Hauses hinaus übrig ist | 0 … Spitzenleistung deiner Module | Energiemanager oder Netzzähler |
soc_pct | % | Ladestand des Akkus | 0 … 100 | Batteriemanagement |
battery_floor_pct | % | Auf oder unter diesem Stand zählt die nächste kWh als Netzstrom | Beispiel 55 | Einstellung |
cop | – | Wärme je Einheit Strom (Leistungszahl) | etwa 2,5 bei −6 °C … über 6 bei mildem Wetter | choose_cop() |
gas_price_eur_kwh | €/kWh | Gaspreis je kWh Gas | etwa 0,08 … 0,12 | Gastarif |
efficiency | – | Wirkungsgrad des Kessels, gehalten zwischen 0,5 und 0,99 | 0,90 … 0,98 | Datenblatt oder eigene Messung |
gas_heat_eur_kwh | €/kWh Wärme | Gaspreis ÷ Wirkungsgrad (+ optionaler Aufschlag) | etwa 0,10 | gas_heat_price() |
heat_pump_heat_eur_kwh | €/kWh Wärme | Preis der Quelle ÷ COP | 0 … 0,15 | berechnet |
An dein Haus anpassen
- Du bekommst Geld für eingespeisten Sonnenstrom? Dann kostet eine PV-kWh im Akku, was du für sie hättest bekommen können:
MeterSettings(solar_price_eur_kwh=…)auf deine Einspeisevergütung setzen. - Viertelstundenpreise: jedem
book_interval()-Aufruf den Preis der aktuellen Viertelstunde mitgeben. Den Rest erledigt das Konto. - Verluste: nicht abgebildet. Ein einfacher Weg: die am Akku gemessene Energie buchen und den Netzpreis durch den Ladewirkungsgrad teilen.
- Konto über einen Neustart retten: Es lebt nur im Arbeitsspeicher. Die vier Zahlen (
energy_kwh,cost_eur,solar_in_kwh,grid_in_kwh) speichern und beim Anlegen des Kontos wieder übergeben. - Das Alter jedes Messwerts prüfen, bevor er übergeben wird. Ein Messwert, der auf seinem letzten Stand eingefroren ist, sieht aus wie ein echter; ein eingefrorener PV-Wert in der Nacht sähe aus wie kostenloses Laden.
- Andere Wärmequelle oder Klimaanlage: Derselbe Vergleich funktioniert, Preis der Quelle ÷ COP gegen den Preis der anderen Quelle.
Arbeitest du mit einem KI-Assistenten? Gib ihm das Paket, VARIABLEN.md und die Namen deiner eigenen Messwerte und lass ihn den Adapter zwischen deinen Messwerten und book_interval() schreiben. Die Tests zeigen dir danach, ob die Logik noch stimmt.
Download
Code-Paket: Akku-Konto
Quellcode, zwei Beispiele, 49 Tests, die Variablenliste und Hinweise zur Herkunft. Nur Python-Standardbibliothek.
- Datei
akkukonto-1.0.0.zip- Version
- 1.0.0 · 6. Oktober 2026
- Größe
- 22 KB · 13 Dateien
- Lizenz
- MIT · siehe Hinweis im Impressum
- Preis
- Kostenlos
- SHA-256
dba504842c478434a95c46e7353d2054283b2294b419a5a1bf87686207b323a0
Zip herunterladen (22 KB)Prüfsummendatei
Zum Prüfen des Downloads die SHA-256-Prüfsumme vergleichen: certutil -hashfile akkukonto-1.0.0.zip SHA256 unter Windows, shasum -a 256 akkukonto-1.0.0.zip unter macOS, sha256sum akkukonto-1.0.0.zip unter Linux.
Stand und Herkunft
Quellen
- Gewichtetes arithmetisches Mittel (Wikipedia)(extern)
- Exponentielle Glättung (Wikipedia)(extern)
- Leistungszahl (Wikipedia)(extern)
- SMARD – Strommarktdaten für Deutschland, u. a. Großhandelspreise (Bundesnetzagentur)(extern)
- pytest-Dokumentation (englisch)(extern)
- MIT-Lizenz (Wikipedia)(extern)
- Quellcode von EnergyPilot 3, Version 3.96.1: gelesen, vereinfacht, nicht veröffentlicht (eigener Code)
Links geprüft am 6. Oktober 2026.