Zum Inhalt springen

Berechnungsreferenz

Version 4.0 | Stand: 2026-07-25

Dieses Dokument beschreibt alle Berechnungsketten im eedc-System: von den Eingabefeldern über die Berechnungslogik bis zur Anzeige im Frontend. Es dient als Referenz zur Fehlersuche und zum Verständnis der Datenflüsse.

Fachliche Fassung für Anwender: Die Wärmepumpen-, Klimaanlagen- und Heizstab-Formeln aus §3.5 sind in Wärme & Klima ohne Formelzeichen beschrieben — samt der Frage, warum eine Arbeitszahl verschwindet.


  1. Datenmodell (3 Schichten)
  2. Konstanten
  3. Berechnungsketten nach Thema
  4. Prognosen (Aussichten)
  5. Tarif-System (Spezialtarife)
  6. Investitionstyp-spezifische Berechnungen (ROI-Dashboard) 6b. Energieprofil-Berechnungen (Tages-Aggregation)
  7. Debugging-Leitfaden

TabelleFelderQuelleBeschreibung
Monatsdateneinspeisung_kwh, netzbezug_kwhZählerwerte (manuell/HA)Anlagen-Energiebilanz
InvestitionMonatsdatenverbrauch_daten (JSON)Manuell/Wizard/HAPro Komponente: PV-Erzeugung, Speicher, WP, E-Auto, etc.
Strompreisnetzbezug_arbeitspreis_cent_kwh, einspeiseverguetung_cent_kwh, grundpreis_euro_monat, verwendungManuellTarife mit Gültigkeitszeitraum
Investitionanschaffungskosten_gesamt, parameter (JSON)ManuellKosten, technische Parameter
Anlageleistung_kwp, steuerliche_behandlung, ust_satz_prozentManuellAnlage-Stammdaten
PVGISPrognosemonatswerte, module_monatswerte, jahresertrag_kwhPVGIS APISOLL-Werte pro Monat/Modul
TagesEnergieProfilpv_kw, verbrauch_kw, einspeisung_kw, netzbezug_kw, batterie_kw, soc_prozent, komponenten (JSON)Scheduler/Monatsabschluss24 Zeilen/Tag, stündliche kW-Werte + Wetter
TagesZusammenfassungueberschuss_kwh, defizit_kwh, peak_pv_kw, batterie_vollzyklen, performance_ratioAggregiert aus TagesEnergieProfil1 Zeile/Tag, Tagessummen + KPIs

Legacy-Felder (NICHT neu befüllen):

  • Monatsdaten.batterie_* - Nutze InvestitionMonatsdaten (Speicher)
  • Monatsdaten.pv_erzeugung_kwh - kein Schreibziel für neuen Code (Pro-Modul-Werte gehören in InvestitionMonatsdaten) und seit 2026-07-29 auch keine allgemeine Lesequelle mehr: das Feld trägt den manuell erfassten oder importierten PV-Gesamtwert eines Monats und ist ausschließlich Eingang des Read-time-SoT core/berechnungen/pv_verteilung.py (resolve_pv_je_modul). Der füllt damit die Lücken der Module ohne eigenen Wert und kennzeichnet sie als gerechnet. Wer nur einen Gesamt-Sensor hat, pflegt weiterhin ausschließlich hier. Jede einzelne Berechnung liest die Pro-Modul-Schicht bzw. deren Summe — nie das Feld selbst. Ladepfad: services/pv_monatswerte.py.

Seit 2026-07-31 ist die Lesequelle nicht mehr lade_pv_je_monat, sondern eine Schicht darüber: services/monats_fakten.py::lade_monats_fakten (ADR-002/P10, Konzept). Sie liefert die ganze Monatszeile kanonisch aufgelöst — die PV ist darin ein Feld (erzeugung.pv_module_kwh bzw. erzeugung.pv_kwh), daneben stehen Zähler, Speicher, E-Mobilität, Wärmepumpe, Sonstiges, Tarif, §51 und die Verbrauchs-Kennzahlen. Sie ruft lade_pv_je_monat (die P7-Regel bleibt unverändert), wendet aber zusätzlich einmal alle Zeitfilter (aktiv · Anschaffung · Stilllegung) und den Dienstwagen-Filter an. Wer eine abgeleitete Monatsgröße auswertet, nimmt sie von dort; lade_pv_je_monat direkt zu rufen bleibt richtig, wo nur die Pro-Modul-PV gebraucht wird (String-Vergleich, PV-Diagnose). Ausgenommen sind Schreib-, Import- und Checker-Pfade — die Schicht ist reines Lesen.

Die Migration läuft sichtweise (KONZEPT-MONATS-FAKTEN.md §10): umgehängt sind Aussichten, Jahresbericht-PDF und der Investitions-ROI (S2). Der baumweite Wächter wird mit S5 scharf gestellt.

Achtung, ein Name für zwei Größen: pv_erzeugung_kwh bezeichnet drei verschiedene Dinge, je nachdem, wo es steht — die DB-Spalte Monatsdaten.pv_erzeugung_kwh (manuelles Gesamt-Aggregat, s. o.), den Schlüssel in InvestitionMonatsdaten.verbrauch_daten (Erzeugung dieses einen Moduls) und das Response-Feld von /monatsdaten/aggregiert (PV-Module + Balkonkraftwerk). Der Identifier bleibt bewusst unverändert — er ist zugleich MQTT-Topic-Segment, CSV-Spaltenname und Backup-Feld. Siehe Glossar.

Und eine vierte Stelle, die es NICHT gibt: der Wechselrichter. Er ist kein PV-Erzeuger — PV_ERZEUGER_TYPEN = ("pv-module", "balkonkraftwerk"), baumweit. Bis 2026-08-24 bot die Eingabe-Registry trotzdem ein wechselrichter/pv_erzeugung_kwh an; gelesen wurde es von niemandem (gemessen: 900 kWh dort ⇒ 0,0 kWh Anlagen-PV), und weil es der Alternativ-Gruppe pv_energie angehörte, setzte ein dort zugeordneter Zähler den Anlagen-Zähler und das Modul-Feld auf „bereits an anderer Stelle zugeordnet”. Ergebnis: drei PV-Quellen inaktiv, keine wirksam, und die Live-Kachel fiel auf die Hochrechnung aus der Leistung zurück (#388, rund 31 % zu hoch). Das Feld trägt seither nur_bestand — es erscheint nur noch, solange eine alte Zuordnung daran hängt, und der Daten-Checker sagt, wohin sie gehört. Wer die Gruppe erweitern will, liest zuerst den Wächter test_wechselrichter_kein_pv_erzeuger.py::test_es_gibt_genau_drei_pv_energie_quellen: er hält Eingabe-Registry und Rechen-Kern gegeneinander und meldet rot, sobald sie auseinanderlaufen.

DateiFunktionenBeschreibung
services/monats_fakten.pylade_monats_fakten(), finanz_zeile_eingabe(), kennzahlen_aus_fakten()Eingabe-Aufbereitung, keine Formel (ADR-002/P10): löst die Monatszeile einmal auf und ruft die SoT-Helfer. Vorschaltet jeder aggregierenden Lese-Sicht
core/calculations.pyberechne_monatskennzahlen(), berechne_speicher_einsparung(), berechne_eauto_einsparung(), berechne_waermepumpe_einsparung(), berechne_roi(), berechne_ust_eigenverbrauch()Reine Berechnungsfunktionen ohne DB-Zugriff
api/routes/cockpit.py6 EndpointsAggregation aller Daten für Dashboard
api/routes/aussichten.py4 EndpointsPrognosen und Finanzberechnungen
api/routes/investitionen.pyROI-DashboardPV-System-Gruppierung und ROI pro Komponente
api/routes/strompreise.pylade_tarife_fuer_anlage()Multi-Tarif-Lookup mit Fallback
utils/sonstige_positionen.pyberechne_sonstige_summen()Strukturierte Erträge/Ausgaben
services/energie_profil_service.pyaggregate_day(), rollup_month(), backfill_range()Tages-Aggregation + Monats-Rollup

Die API-Endpoints sind unverändert; die Sicht (Spalte „Wo in v4”) folgt der neuen Achsen-Navigation (Cockpit = Zeit-Achse, Komponenten = Was-Achse, Auswertungen = Wie-Achse — siehe Bedienung):

Wo in v4API-EndpointAngezeigte Kennzahlen
Cockpit → Monat/JahrGET /api/cockpit/uebersicht/{id}?jahr=Autarkie, EV-Quote, Netto-Ertrag, Rendite, CO2
Auswertungen → PrognoseGET /api/cockpit/prognose-vs-ist/{id}?jahr=Performance Ratio pro Monat
Cockpit → Jahr/Gesamt · Auswertungen → CO₂ (§4.4)GET /api/cockpit/nachhaltigkeit/{id}CO2-Zeitreihe (Block „CO₂-Bilanz”), Äquivalente, Amortisation — die eine CO₂-Quelle beider Sichten (§3.8)
Komponenten (je Typ)GET /api/cockpit/komponenten-zeitreihe/{id}Speicher-Effizienz, WP-JAZ, E-Auto PV-Anteil
Komponenten → PV-AnlageGET /api/cockpit/pv-strings/{id}?jahr=SOLL vs IST pro String
Auswertungen → ROIGET /api/investitionen/roi/{id}ROI%, Amortisation pro System
Auswertungen → TabelleGET /api/monatsdaten/aggregiert/{id}Spalten-Explorer, Vorjahres-Delta
Cockpit → AussichtGET /api/aussichten/kurzfristig/{id}7-Tage PV-Prognose + SFML-Linie
Cockpit → AussichtGET /api/aussichten/langfristig/{id}12-Monats-Prognose
Cockpit → AussichtGET /api/aussichten/trend/{id}Degradation, Jahresvergleich
Auswertungen → FinanzenGET /api/aussichten/finanzen/{id}Amortisations-Fortschritt, Prognose

Definiert in core/calculations.py:

KonstanteWertEinheitVerwendung
CO2_FAKTOR_STROM_KG_KWH0.38kg CO2/kWhDeutscher Strommix
CO2_FAKTOR_BENZIN_KG_LITER2.37kg CO2/LBenzinverbrennung
CO2_FAKTOR_GAS_KG_KWH0.201kg CO2/kWhErdgasverbrennung
CO2_FAKTOR_OEL_KG_KWH0.266kg CO2/kWhHeizölverbrennung
SPEICHER_ZYKLEN_PRO_JAHR250VollzyklenFür Speicher-Prognose

Definiert in api/routes/aussichten.py:

KonstanteWertVerwendung
DEFAULT_SYSTEM_LOSSES0.14 (14%)Kurzfrist-PV-Prognose
TEMP_COEFFICIENT0.004 (0.4%/°C)Leistungsabnahme über 25°C
Konfidenz-Faktor0.15 (15%)Langfrist-Konfidenzband

Hardcodierte Werte in cockpit.py:

WertVerwendung
Gas-Preis: 10.0 ct/kWhWP-Ersparnis (vs. Gas)
Gas-Wirkungsgrad: 0.9 (90%)WP CO2-Vergleich
Benzin-Verbrauch: 7.0 L/100kmE-Mob-Ersparnis
Benzin-Preis: 1.80 EUR/LE-Mob-Ersparnis (Cockpit-Fallback)

Funktion: berechne_monatskennzahlen() in core/calculations.py Verwendet in: Cockpit-Übersicht (inline Berechnung), Monatsdaten-Anzeige

FeldQuelleTabelle
einspeisung_kwhZählerMonatsdaten
netzbezug_kwhZählerMonatsdaten
pv_erzeugung_kwhPV-ModuleInvestitionMonatsdaten.verbrauch_daten (Typ: pv-module)
batterie_ladung_kwhSpeicherInvestitionMonatsdaten.verbrauch_daten (Typ: speicher)
batterie_entladung_kwhSpeicherInvestitionMonatsdaten.verbrauch_daten (Typ: speicher)
v2h_entladung_kwhE-Auto V2HInvestitionMonatsdaten.verbrauch_daten (Typ: e-auto)
einspeiseverguetung_centTarif, ggf. MonatswertStrompreis.einspeiseverguetung_cent_kwh; bei variabler Vergütung schlägt Monatsdaten.einspeise_durchschnittspreis_cent (#392, resolve_einspeise_preis_cent)
netzbezug_preis_centTarifStrompreis.netzbezug_arbeitspreis_cent_kwh
grundpreis_euro_monatTarifStrompreis.grundpreis_euro_monat
netzbezug_durchschnittspreis_centHA-Sensor oder MonatsdatenDynamischer Ø-Preis
leistung_kwpAnlageSumme aller Investition.leistung_kwp (pv-module)
Erzeugung_gesamt = PV_Erzeugung + BKW + sonstige_Erzeuger (hinter dem Zähler)
Direktverbrauch = max(0, Erzeugung_gesamt - Einspeisung - Batterie_Ladung)
Eigenverbrauch = Direktverbrauch + Batterie_Entladung + V2H_Entladung
Gesamtverbrauch = Eigenverbrauch + Netzbezug
EV-Quote (%) = Eigenverbrauch / Erzeugung_gesamt * 100 (wenn Erzeugung > 0)
Autarkie (%) = Eigenverbrauch / Gesamtverbrauch * 100 (wenn GV > 0)
Spez. Ertrag = PV_Erzeugung / Leistung_kWp (kWh/kWp, NUR PV; zwei Varianten, s. u.)
Einspeise-Erlös (EUR) = (Einspeisung - Einspeisung_neg_Preis) * Einspeisevergütung / 100
Netzbezug-Kosten (EUR) = Netzbezug * Netzbezug_Preis / 100 + Grundpreis
Arbeitspreis-Kosten (EUR)= Netzbezug * Netzbezug_Preis / 100 (ohne Grundpreis, reiner Ausweis)
EV-Ersparnis (EUR) = Eigenverbrauch * Netzbezug_Preis / 100
Netto-Ertrag (EUR) = Einspeise-Erlös + EV-Ersparnis
CO2-Einsparung (kg) = PV_Erzeugung * 0.38 (VERALTET — s. Kasten)

⚠ Die CO₂-Zeile dieser Funktion ist NICHT der Kanon. berechne_monatskennzahlen trägt noch die vor DI-2 gültige Formel (Erzeugung statt Eigenverbrauch, ohne WP und E-Mobilität). Sie wird ausschließlich von GET /api/monatsdaten/{id} als kennzahlen.co2_einsparung_kg ausgeliefert und dort von keiner Sicht gelesen (gemessen 2026-07-31: das Feld existiert im Client-Typ MonatsKennzahlen, es gibt keinen Leser). Sie bewegt also keine angezeigte Zahl — sie steht hier, damit niemand sie für die gültige Definition hält. Der Kanon ist §3.8.

Zwei Kostenzahlen, eine Rechnung — netzbezug_kosten_euro vs. netzbezug_arbeitspreis_kosten_euro: verrechnet wird immer die Gesamtsumme inkl. Grundpreis; sie ist das, was auf der Rechnung steht, und hängt an T-Konto, Netto-Ertrag und den Finanz-Sichten. Daneben steht der reine Arbeitspreis-Anteil als Ausweis — kein zweiter Kostenposten. Er gehört überall dorthin, wo eine Sicht kWh und € so nebeneinander stellt, dass ein Leser sie dividiert: dann muss der Ø-Preis herauskommen. Die Ø-Preis-Kachel in Cockpit → Monat tat das mit den Gesamtkosten nicht (559 kWh · 210,45 € ⇒ 37,6 ct statt 33 ct; Forum simon42 #89667). Faustregel: neben einem Preis steht der Arbeitspreis-Anteil, in einer Kostenaufstellung die Gesamtsumme.

Welcher Preis gilt: bei einem flexiblen Tarif der verbrauchsgewichtete Monatsdurchschnitt (Monatsdaten.netzbezug_durchschnittspreis_cent), sonst der Tarif-Arbeitspreis. Das gilt für Netzbezug-Kosten, EV-Ersparnis und — ohne eigenen WP-Tarif — auch für die WP-Ersparnis. Bis v4.0.6 nahm der laufende Monat hier den Tarifpreis, während Vorjahres-Vergleich und die per-Investition-Details schon den Durchschnitt nahmen; derselbe Monat trug damit je nach Sicht zwei Beträge.

§51 EEG im Einspeise-Erlös: Einspeisung_neg_Preis sind die kWh, die in Stunden mit negativem Börsenpreis eingespeist wurden — für betroffene Anlagen entfällt dafür die Vergütung (Herleitung des Volumens: Abschnitt „§51 EEG (Negativpreis-Analyse)”). Ist die Anlage nicht §51-pflichtig oder liegt keine Strompreis-Mitschrift vor, ist der Wert null und es wird nichts abgezogen. Der Abzug gilt überall gleich, und es gibt dafür eine Implementierung: core/berechnungen/einspeise_erloes.py. Bis 2026-08-04 stand daneben ein Frontend-Spiegel (lib/calculations.ts::calcEinspeiseErloes) für Auswertungen → Finanzen + Tabelle; er ist mit Fund N-22 entfallen, weil die Monats-Finanzzeile jetzt fertig aus /monatsdaten/aggregiert kommt.

Netzpunkt-Bilanz (Erzeugung_gesamt): Am EINEN Netzanschluss messen die Zähler (Einspeisung/Netzbezug) die Summe aller dahinter liegenden Erzeuger. Deshalb geht in die Eigenverbrauchs-/Autarkie-Ableitung die Gesamterzeugung ein — PV-Module + Balkonkraftwerk + sonstige Erzeuger (z. B. Mini-BHKW). Würde ein Erzeuger ignoriert, drückte der gemessene Einspeise-Zähler Direktverbrauch zu niedrig (auf 0 geklemmt) und Autarkie/EV-Quote würden unterschätzt. SoT-Helper: core/berechnungen/energie.erzeugung_hinter_zaehler_kwh (ADR-001).

Die Größe ist ein eigenes Response-Feld, kein Rechenschritt je Sicht. /monatsdaten/aggregiert liefert sie als erzeugung_hinter_zaehler_kwh mit — vorher summierte jede Sicht die Einzelteile selbst, und genau dort entstand die Drift (zwei unterschiedlich hohe Stapel im Komponenten-Hub). Die Felder derselben Antwort:

FeldBedeutung
pv_module_kwhnur die PV-Module (bis v4.0.0 pv_anlage_kwh — irreführend, weil „PV-Anlage” im Produkt sonst die ganze Anlage inklusive Balkonkraftwerk meint)
bkw_kwhnur Balkonkraftwerk(e)
sonstige_erzeugung_kwh„Sonstiges” mit Kategorie Erzeuger (z. B. Mini-BHKW)
pv_erzeugung_kwhpv_module_kwh + bkw_kwhnicht die gleichnamige DB-Spalte (s. Schicht 1)
erzeugung_hinter_zaehler_kwhΣ aller drei Erzeuger-Felder = der Nenner der EV-Quote

Ein Balkonkraftwerk mit zugeordneten PV-Modulen (ADR-002/P11)

Abschnitt betitelt „Ein Balkonkraftwerk mit zugeordneten PV-Modulen (ADR-002/P11)“

Ein Balkonkraftwerk trägt eine Ausrichtung und eine Neigung. Wer seine Module über Eck hängen hat (Balkon und Terrasse, Ost und West), legt sie deshalb als PV-Module an und ordnet sie unter Gehört zu dem Balkonkraftwerk zu — jedes Modul trägt dann seine eigene Ausrichtung, und die Prognose rechnet sie getrennt.

Damit tritt das Balkonkraftwerk drei Größen an seine Module ab, und zwar immer gemeinsam: die Nennleistung (die Anzeige am Gerät zeigt die Σ seiner Module), die Ausrichtung/Neigung und die Erzeugung. Für die Erzeugung gilt dieselbe Leserichtung wie beim Anlagen-Aggregat (P7), nur eine Ebene tiefer — die Präzedenz hat damit drei Stufen:

  1. der gemessene Wert des Moduls gewinnt,
  2. der Monatswert des Balkonkraftwerks füllt die Lücken seiner Module (nach kWp verteilt),
  3. das Anlagen-Aggregat Monatsdaten.pv_erzeugung_kwh füllt, was danach noch offen ist.

Der Monatswert am Balkonkraftwerk bleibt also voll erfassbar und zuordenbar — bei einem Set ist der Wechselrichter oft der einzige Zähler, und die Module darunter haben gar keinen eigenen. Er zählt dann aber nicht mehr zusätzlich in bkw_kwh: pv_erzeugung_kwh wäre sonst doppelt, mit Folgen für Autarkie, EV-Quote, CO₂, Finanzen, Community-Datensatz und HA-Sensoren.

Nicht abgetreten wird die AC-Grenze. Die Wechselrichter-Leistung (W) gehört dem Gerät und gilt weiter für die Summe seiner Module — sie ergibt sich gerade nicht aus ihnen. Das sind zwei unabhängige Grenzen: 800 VA am Wechselrichter-Ausgang und (regulatorisch) 2.000 Wp an den Modulen; nur die zweite wächst mit den Kindern. Für die Kappung wechselt das Balkonkraftwerk deshalb die Rolle vom Erzeuger zum Träger — genau die Rolle, die ein Wechselrichter für seine Strings hat.

Ein Balkonkraftwerk ohne zugeordnete Module rechnet unverändert wie bisher. SoT: core/berechnungen/erzeuger_traeger.py. Melder: Discussion #366, Forum T89667 #172.

Dieselbe Abtretung gilt für die Wirtschaftlichkeit (ab v4.0.19, F-33/#381)

Abschnitt betitelt „Dieselbe Abtretung gilt für die Wirtschaftlichkeit (ab v4.0.19, F-33/#381)“

Die ROI-Sicht fasst einen Wechselrichter mit seinen Modulen und seinem DC-Speicher seit jeher zu einer Systemzeile zusammen: die drei Positionen teilen sich eine Energiemenge, und ihre Ersparnisse sind deshalb nicht addierbar. Für ein Balkonkraftwerk als Träger galt das nicht — dort bekam jede Ebene ihre eigene Zeile, und die Gesamtzeile addierte sie. An einer gemeldeten Anlage (BKW + Modul-Kind + Speicher-Kind) standen so 1.079 €/Jahr statt der gemessenen 606 €, und die Gesamt-Amortisation bei 1,9 statt 3,3 Jahren. Das Modul-Kind trug zusätzlich die Aufforderung „(ohne WR) — bitte zuordnen”, obwohl es zugeordnet war.

Seither ist Trägergerät die Rolle, nicht der Typ: ein Balkonkraftwerk mit Kindern ist Systemkopf wie ein Wechselrichter. Es tritt seine Ersparnis genauso an die Module ab, wie es oben seine Erzeugung abtritt — hat es keine Modul-Kinder (nur einen Speicher), trägt es sie weiterhin selbst und steuert sie der Systemzeile bei. Die Zeile trägt Name und Typ ihres Kopfes, damit ein Balkonkraftwerk nicht als „PV-System” mit fremdem Namen erscheint.

Die pauschale BKW-Schätzung ist davon unberührt und bleibt eine Schätzung (0,9 kWh/Wp, 80 % Eigenverbrauch). Sie greift nur noch für ein Balkonkraftwerk ohne Modul-Kinder — dort, wo keine gemessenen Modulwerte existieren. Melder: Issue #381 (azywietz-web).

Auch die PDF-Berichte rechnen so. Der Jahresbericht leitete Eigenverbrauch, Autarkie und EV-Quote bis v4.0.0 allein aus der PV-Erzeugung ab, während der Einspeise-Zähler daneben die Summe aller Erzeuger misst — bei einer Anlage mit sonstigem Erzeuger fielen die Werte dort zu niedrig aus und widersprachen dem Cockpit. Seit v4.0.1 nutzen alle Bilanz-Pfade (Cockpit, Monatsbericht, Live, PDF-Jahresbericht, HA-Sensoren) dieselbe Größe.

Spezifischer Ertrag — zwei Größen, ein Formelname. Die Roh-Division oben ist nur eine davon:

GrößeRechenwegWo
Annualisiertsaisonal gewichtet (PVGIS-Monatsverteilung) und mit der im jeweiligen Monat tatsächlich installierten Leistung — vergleichbar über verschieden lange ZeiträumeCockpit, HA-Export, Community-Vergleich (SoT core/berechnungen/spez_ertrag.py)
ZeitraumErzeugung des Berichtszeitraums ÷ Anlagen-Nennleistung, ohne Normierung — summiert sich über mehrere Jahre aufPDF-Jahresbericht (dort seit v4.0.1 als „Spez. Ertrag (Zeitraum)” beschriftet), Monatsbericht

Beide Zahlen sind richtig, sie beantworten verschiedene Fragen. Eine Angleichung der Rechnung steht aus, weil dieselbe Kennzahl im Community-Vergleich steht.

Achsen-Trennung (bewusst): PV-eigene Kennzahlen (spez. Ertrag, Performance- Ratio, SOLL/IST, kWp) nutzen nur PV_Erzeugung, nicht Erzeugung_gesamt — ein sonstiger Erzeuger ist energetisch Erzeuger, aber kein PV-Modul. Ebenso bleibt CO₂/Wirtschaftlichkeit quellenspezifisch: ein brennstoffbasierter Erzeuger (BHKW) spart kein CO₂, sondern emittiert, und hat Brennstoffkosten — er bekommt daher keine PV-artige CO₂-Ersparnis (bewertet als „nicht bewertet”, bis ein eigenes BHKW-Modell existiert). Insel-Anlagen (kein Netzanschluss, kein Bezug/keine Einspeisung) fallen nicht unter diese Bilanz — das ist ein Anlagen-Merkmal (eigenes KZ, geplant).

Messpunkt der Sensoren (DC vs. AC): Die Bilanz rechnet mit den Werten, die die Geräte liefern — sie kann nicht wissen, wo gemessen wurde. Viele Hybrid-Wechselrichter (z. B. E3DC) melden PV-Erzeugung und Speicher-Ladung/-Entladung DC-seitig (Modul- bzw. Batterieklemme), Einspeisung und Netzbezug dagegen AC-seitig (Zähler). Dann stecken die Wandlungsverluste der PV- und Speicherstrecke im bilanzierten Gesamtverbrauch: er liegt typischerweise 3–5 % der Erzeugung über dem „Hausverbrauch”, den das Herstellerportal ausweist — das rechnet seine Verluste intern heraus. Keiner der beiden Werte ist falsch, sie beantworten verschiedene Fragen: eedc „was musste die Anlage liefern” (inklusive Verluste — die richtige Basis für Autarkie, EV-Quote und Wirtschaftlichkeit, denn erzeugt und bezahlt werden muss auch der Verlust), das Portal „was zogen die Verbraucher”.

Diagnose-Rezept für einen abgeschlossenen Tag:

Residuum = PV + Netzbezug + Entladung − Einspeisung − Ladung − Hausverbrauch(Portal)

Liegt das Residuum bei wenigen Prozent der Erzeugung, ist es der Verlustanteil und kein Rechenfehler. Gemessenes Beispiel (E3DC, 03.07.2026, Issues #200/#340): 60,83 + 0,31 + 7,17 − 48,19 − 7,59 − 10,26 = 2,27 kWh = 3,7 % der Erzeugung — davon 0,42 kWh Batterie- Rundlauf (DC), der Rest DC→AC-Wandlung. Da die Verluste mit dem Durchsatz skalieren, liegt ein ertragsstarker Tag über dem Monatsschnitt.

Bewusst kein Hausverbrauchs-Sensor: eedc bietet kein Mapping eines fremden Hausverbrauchs-Sensors an. „Hausverbrauch” ist je Hersteller anders definiert (Verluste drin oder herausgerechnet, Wallbox enthalten oder nicht) — zwei Definitionen derselben Kennzahl würden Cockpit, Berichte und Community-Vergleich auseinanderlaufen lassen. Die Bilanz aus Zähler- und Komponentenwerten bleibt die eine Wahrheit; die Differenz wird erklärt, nicht durch eine zweite Datenquelle ersetzt.

Wichtig: Netto_Ertrag enthält NICHT den Abzug der Netzbezugskosten, da diese auch ohne PV angefallen wären.

Endpoint: GET /api/cockpit/uebersicht/{anlage_id} in cockpit.py

Die Cockpit-Übersicht aggregiert alle Monatsdaten für ein Jahr (oder alle Jahre) und berechnet:

Einspeise-Erlös = Σ(Einspeisung) * Einspeisevergütung / 100
EV-Ersparnis = Σ(Eigenverbrauch) * Netzbezug_Preis / 100
Netto-Ertrag = Einspeise-Erlös + EV-Ersparnis [- USt_Eigenverbrauch]
BKW-Ersparnis = Σ(BKW_Eigenverbrauch) * Netzbezug_Preis / 100
Sonstige-Netto = Σ(sonstige_ertraege) - Σ(sonstige_ausgaben)
Betriebskosten_Zeitraum = Σ(Betriebskosten_Jahr) * Anzahl_Monate / 12
Kumulative Ersparnis = Netto-Ertrag + WP-Ersparnis + E-Mob-Ersparnis
+ BKW-Ersparnis + Sonstige-Netto
- Betriebskosten_Zeitraum
Jahres-Rendite (%) = Kumulative_Ersparnis / Investition_gesamt * 100

Die Einspeisevergütung ist ein flacher Satz — eedc kennt keine EEG-Leistungsstaffel. Einspeisevergütung ist genau der Wert aus dem für den Monat gültigen Tarif (Strompreis.einspeiseverguetung_cent_kwh); es gibt keine Ableitung aus leistung_kwp, keine Stufengrenze und keine Aufteilung der eingespeisten Menge auf mehrere Sätze. Wer gestaffelt vergütet wird, trägt den nach kWp gewichteten Mischsatz ein — mathematisch identisch, weil das EEG nach installierter Leistung staffelt und nicht nach eingespeister Menge. Der einzige Automatismus ist der Fallback EINSPEISEVERGUETUNG_DEFAULT_CENT (core/wirtschaftlichkeit_defaults.py), der ausschließlich greift, wenn gar kein Tarif gepflegt ist — und genau das meldet der Daten-Checker. Anwendersicht: Einstellungen §2.2.

Variable Einspeisevergütung (#392, seit 2026-08-22): Trägt der Tarif das Häkchen „Einspeisevergütung wechselt monatlich“ (Strompreis.einspeisung_variabel, z. B. OeMAG-Marktpreis in Österreich), bietet der Monatsabschluss das Feld Monatsdaten.einspeise_durchschnittspreis_cent an. Der gepflegte Monatssatz schlägt den Stammwert — aufgelöst ausschließlich über resolve_einspeise_preis_cent (api/routes/strompreise.py), mit is not None: 0 ct ist ein Wert. Monate ohne Eintrag rechnen mit dem Stammwert. Die Hochrechnung nach vorn (Aussichten-Prognose) und die ROI-Rechnung über die Lebensdauer nehmen bewusst weiter den heutigen Stammwert — künftige Monate haben keinen Monatswert (dieselbe Begründung wie beim Netzbezug, N-113). eedc holt den Satz nicht automatisch ab (keine länderspezifische Quelle); er wird eingetragen oder per CSV importiert (Spalte Einspeiseverguetung_Cent).

Kanonisches Finanz-Aggregat (SoT core/berechnungen/finanz_aggregat.py): Netto-Ertrag, Einspeise-Erlös, EV-/BKW-Ersparnis und Sonstige-Netto werden per-Monat gerechnet und über die sichtbaren Monate summiert (nicht mit einem Ø-Preis) — bei Flex-Tarifen (Tibber/aWATTar/EPEX) laufen Monats-Preis und EV/Netzbezug-Split sonst auseinander (#326). Der naive netto_ertrag_euro = Einspeise-Erlös + EV-Ersparnis + BKW-Ersparnis + Sonstige-Netto; Sites mit Zusatzlogik (Cockpit zieht USt_Eigenverbrauch ab) bauen den Netto-Ertrag aus den Einzel-Komponenten selbst zusammen.

G19-1 — Sonstige Positionen auf Anlage-Ebene (ab v4.0): Sonstige-Netto umfasst jetzt auch die auf Anlage-Ebene (nicht nur pro Komponente) erfassten Positionen — siehe §3.10. Sie fließen als eigene T-Konto-Zeilen „Anlage — Sonstige Erträge/Ausgaben” in fünf Anlage-Finanz- Pfade (Cockpit-Monat/-Jahr, Übersichts-Netto, PDF-Jahresbericht, HA-Export-Netto-Sensor). Wichtig für Bestandsdaten: die früher rein informativen Monatsdaten.sonderkosten_euro (die nirgends rechneten) werden migriert und wirken ab v4.0 in den Finanz-Summen. Bewusste Inkonsistenz: Die Aussichten- und ROI-Pfade (investitions-zentrierte Amortisation, s. §3.6 / §4.4) sehen die Anlage-Positionen NICHT — sie fließen dort nicht in Bisherige-Erträge/Amortisation ein. Das ist so dokumentiert, kein Bug (offener Entscheid unter „Kennzahlen-Drift-Inventur”).

Grundgebühr / Zählergebühr (K3, ab v4.0): Die Grundgebühr (Strompreis.grundpreis_euro_monat) steckt bereits in den Netzbezugskosten (s. §3.1); der Cockpit-Finanz-Teaser (Monat + Jahr) weist sie zusätzlich nachrichtlich aus („davon Grundgebühr: … €”). Die Zählergebühr (neues optionales Tarif-Feld Strompreis.zaehlergebuehr_euro_jahr) wird im Jahr-Modus als „Zählergebühr: … €/Jahr (nachrichtlich)” gezeigt, aber nicht in Kosten/Netto verrechnet — eine Einrechnung wäre ein eigener Kennzahlen-Entscheid. baueJahrAlsMonat: Grundgebühr = Σ, Zählergebühr = letzter Wert.

Cockpit-Finanzen-Block = Komponenten-Finanz-Tabelle (G20-1, ab v4.0): Der Finanzen-Block in Cockpit-Monat/-Jahr zeigt eine Zeile je Komponente (Reihenfolge = Typ-SoT) mit den Spalten Erträge (tatsächliche Zahlungsflüsse) · Einsparungen (kalkulatorisch/vermiedene Kosten) · Aufwand (inkl. anteilig umgelegter Betriebskosten, Speicher-Zeile inkl. Netzladungs-Kosten) · Saldo; die Summenzeile ist die Block-Kopf-Kennzahl (Kopf == sichtbare Summe). Diese Tabellen- Summe ist bewusst eine dritte, komponenten-attribuierte Netto-Semantik neben (a) dem kanonischen netto_ertrag_euro (PV-Anlage: Einspeise-Erlös + EV-Ersparnis + BKW-Ersparnis + Sonstige-Netto) und (b) gesamtnettoertrag_euro (Einspeise-Erlös + EV-Ersparnis + WP-Ersparnis + E-Mob-Ersparnis − Netzbezug-Kosten). Sie fasst die Beiträge aller Komponenten zusammen und wird rein aus den vorhandenen T-Konto-Posten gebaut — keine neue Berechnung: netto_ertrag_euro, der HA-Export- Sensor und der PDF-Jahresbericht bleiben unangetastet. Netzbezug-Kosten und Grundgebühr stehen nachrichtlich (nicht im Saldo). Zusätzlich weist der Block als zweite Perspektive die Zeile „Ergebnis nach Stromrechnung” = Tabellen-Saldo − Netzbezug-Kosten (G20-4) aus — das Haushalts- ergebnis; der Komponenten-Saldo bleibt davon unberührt und ist weiterhin die Kopf-Kennzahl. (Die Vergleichs-Asymmetrie gesamtnettoertrag Monat vs. Vorjahr ist ein offener Punkt der Kennzahlen-Drift-Inventur, kein Bug.)

Anschaffungsdatum-Grenze auch im Vorjahres-Vergleich (DI-5/DI-2-C): Der Trend-Pfeil zum Vorjahr zieht die Vorjahres-Werte symmetrisch zum laufenden Monat — WP- und E-Mob-Ersparnis fließen nur für im jeweiligen Vorjahresmonat aktive Komponenten ein (ist_aktiv_im_monat, also innerhalb Anschaffungs-→Stilllegungs-Fenster), und die energieseitige Vorjahres-Aggregation ist gleich gefiltert. Dienstwagen (ist_dienstlich) bleiben in beiden Jahren aus den E-Mob-Bilanzen. So vergleicht der Pfeil gleiche Komponenten-Mengen, statt Alt-Werte vor der Anschaffung mitzuzählen.

WP-Ersparnis = (WP_Wärme / 0.9 * Gas_Preis - WP_Strom * WP_Preis) / 100

Wobei:

  • WP_Wärme = Σ(heizenergie_kwh + warmwasser_kwh) aus InvestitionMonatsdaten
  • WP_Strom = Σ(stromverbrauch_kwh) aus InvestitionMonatsdaten
  • 0.9 = angenommener Gasheizungs-Wirkungsgrad
  • Gas_Preis = 10.0 ct/kWh (hardcodiert)
  • WP_Preis = Spezialtarif waermepumpe (Fallback: allgemein)
Benzin_Verbrauch = Σ(km_gefahren) * 7 / 100 (7 L/100km Annahme)
Benzin_Kosten = Benzin_Verbrauch * 1.80 (1.80 EUR/L Annahme)
Strom_Kosten = (Ladung_gesamt - Ladung_PV) * Wallbox_Preis / 100
E-Mob-Ersparnis = Benzin_Kosten - Strom_Kosten

Hinweis: Dienstliche E-Autos/Wallboxen (ist_dienstlich = true) werden NICHT in die E-Mob-Ersparnis eingerechnet. Deren Ladekosten fließen als kalkulatorische Ausgaben in sonstige_ausgaben_gesamt — Formel und Begründung stehen in §3.10 „Sonstige Positionen” unter Dienstliche Ladekosten — der PV-Anteil zählt dort zum Netzbezugspreis, nicht zur Einspeisevergütung.

G20-2 — Aggregat bei mehreren E-Autos = Σ der Einzel-Fahrzeuge: Die Gesamt-E-Mob-Ersparnis wird als Summe der pro Fahrzeug gerechneten Ersparnisse gebildet — jedes E-Auto mit seinem eigenen Vergleichsverbrauch (L/100 km) und Benzinpreis. Sie ist NICHT ein Einmal-Lauf über die Gesamt-Kilometer mit dem Parametersatz des ersten Fahrzeugs (das überschätzte die Ersparnis, sobald zwei E-Autos unterschiedliche Vergleichsverbräuche hatten). Bei genau einem E-Auto ist das Ergebnis unverändert. Die Per-Fahrzeug-Zeilen (T-Konto) rechneten schon immer je Fahrzeug korrekt; nur das aggregierte Cockpit-Feld ist jetzt symmetrisch dazu.

Kanonische Heimladungs-Quelle (ab Phase 2a): Ladung_gesamt und Ladung_PV der Heimladung kommen strukturell aus genau einer Quelle: existiert eine Wallbox-Investition mit Heimladung, ist sie die Quelle (Infrastruktur misst den Stromfluss am Ladepunkt); ohne Wallbox (Steckerlader/Schuko) liefert das E-Auto die Werte. Bei mehreren Wallboxen ist die Heimladung die Summe aller Wallbox-Ladepunkte. Diese Regel ist deterministisch (existiert eine Wallbox?), nicht magnitudenabhängig — der frühere Pool-/„größere Heimladung gewinnt”-Mechanismus entfällt. Die km-anteilige Aufteilung auf mehrere Fahrzeuge (Attribution) bleibt unverändert. Zentraler Helper: get_emob_heimladung_canonical().

Ø Verbrauch (kWh/100 km) — Quellen-Vorrang: Die Effizienz-KPI in E-Auto-Dashboard, Monatsbericht und Komponenten-Auswertung kommt aus einem Helper (core/berechnungen/emob.py, eauto_effizienz_100km):

1. gemessener Fahrverbrauch: verbrauch_kwh ÷ km × 100 (Vorrang, exakt)
2. sonst Näherung aus Ladung: Ladung_gesamt ÷ km × 100 (Fallback)
3. sonst: — (nie 0,0 erfinden)

Die Ladungs-Näherung überschätzt den echten Fahrverbrauch (AC-Ladung an der Wallbox enthält Ladeverluste ~10–15 %, blendet SoC-Drift + nicht erfasste Fremdladung aus) — in der UI als „≈ aus Ladung (inkl. Ladeverluste)” gelabelt. Vorteil: funktioniert auch ohne Verbrauchssensor (den die wenigsten Fahrzeuge liefern). Alle Read-Sites zeigen denselben Wert; das Aggregat rechnet über die Summen (Σverbrauch / Σladung / Σkm), nicht über das Mittel der Monats-Prozente. Symmetrie abgesichert durch test_emob_readsite_symmetrie.py.

Hinweis Kraftstoffpreis (ab v3.17.0): Im Cockpit werden weiterhin die hardcodierten Defaults verwendet. In der Finanz-Prognose (Auswertungen → Finanzen), im HA-Sensor-Export und im PDF-Finanzbericht wird stattdessen pro Monat der echte Kraftstoffpreis aus Monatsdaten.kraftstoffpreis_euro verwendet (Quelle: EU Weekly Oil Bulletin). Fallback auf den statischen benzinpreis_euro-Parameter der Komponente wenn kein Monatswert vorhanden.

PV-System-Kosten = Σ(Kosten) für pv-module, wechselrichter, speicher, wallbox, bkw
WP-Mehrkosten = max(0, WP_Kosten - alternativ_kosten_euro) (Default: 8.000 EUR)
E-Auto-Mehrkosten = max(0, E-Auto_Kosten - alternativ_kosten_euro) (Default: 35.000 EUR)
Sonstige-Kosten = Σ(Kosten) für andere Typen
Investition_gesamt = PV-System + WP-Mehrkosten + E-Auto-Mehrkosten + Sonstige

Funktion: berechne_speicher_einsparung() in core/calculations.py Verwendet in: ROI-Dashboard (investitionen.py)

FeldQuelle
kapazitaet_kwhget_speicher_nutzbare_kapazitaet_kwh(inv)netto, still auf brutto zurückfallend (A31-2/E17, s. u.). Greift nur im Prognose-Modus; mit gemessener Entladung übernimmt der Spread-Service und liest gar keine Kapazität.
wirkungsgrad_prozentInvestition.parameter["wirkungsgrad_prozent"] (Default: 95)
arbitrage_faehigInvestition.parameter["arbitrage_faehig"] (Default: false) — der Kanon; nutzt_arbitrage war der Name vor v3.25.0
lade_preis_centInvestition.parameter["lade_durchschnittspreis_cent"] (Default: 12)
entlade_preis_centInvestition.parameter["entlade_vermiedener_preis_cent"] (Default: 35)

Ohne Arbitrage (Eigenverbrauchsoptimierung):

Wirkungsgrad = wirkungsgrad_prozent / 100
Nutzbare_Speicherung = Kapazität * 250 Zyklen * Wirkungsgrad
Standard_Spread = Netzbezug_Preis - Einspeisevergütung
Jahres-Einsparung = Nutzbare_Speicherung * Standard_Spread / 100

Mit Arbitrage (70/30-Modell):

PV-Anteil (70%) = Nutzbare_Speicherung * 0.70
Arbitrage-Anteil (30%) = Nutzbare_Speicherung * 0.30
PV-Einsparung = PV-Anteil * Standard_Spread / 100
Arbitrage-Spread = Entlade_Preis - Lade_Preis
Arbitrage-Einsparung = Arbitrage-Anteil * Arbitrage_Spread / 100
Jahres-Einsparung = PV-Einsparung + Arbitrage-Einsparung
Vollzyklen = Entladung_kWh ÷ Kapazität_brutto_kWh

SoT: core/berechnungen/speicher.py::vollzyklen. Alle Sichten rufen ihn auf — Komponenten-Hub (investitionen/dashboards.py), Cockpit Tag (energie_profil/tage_werte.py), Monat/Jahr (aktueller_monat.py) und Cockpit-Übersicht (cockpit/uebersicht.py), PDF-Jahresbericht, HA-Sensor speicher_zyklen. Gewächtert von backend/tests/test_speicher_zyklen_kapazitaets_basis.py (inkl. Drei-Pfad-Symmetrie), test_tage_werte_symmetrie.py und test_speicher_kanon_symmetrie.py.

Diese Aufzählung war bis zum 2026-08-04 unvollständig — und genau die fehlende Zeile war die fehlerhafte. cockpit/uebersicht.py rechnete weiterhin Ladung ÷ Kapazität; der Sweep vom 2026-07-28 hatte die Route übersehen, und weil ihr Wert damals keinen Client-Leser hatte, fiel es niemandem auf (#358 gab ihm einen). Lehre: eine Liste von Aufrufern in der Doku ist eine Behauptung über den Code — sie gehört mit einem baumweiten Grep belegt, sonst dokumentiert sie den Soll- statt den Ist-Zustand.

Warum die Entladung: Ein Vollzyklus meint die einmal entnommene Kapazität — die Größe, auf die sich Hersteller-Garantien beziehen, und unabhängig von den Wandlungsverlusten des Ladepfads. Sie ist außerdem ein Energiedurchsatz und damit über Tag → Monat → Jahr additiv.

Warum Brutto im Nenner: nutzbare_kapazitaet_kwh ist optional und meist nicht gepflegt; ein Nenner, der je nach Pflegezustand wechselt, wäre schlimmer als ein durchgehend leicht konservativer Wert. Im HA-Export ist das Netto-Feld reiner Fallback, falls die Brutto-Kapazität fehlt — die Lese-Reihenfolge dort ist bewusst brutto → netto und bleibt es auch nach A31-2.

Auslastung — zeitraum-normierte Nutzung (#358 Phase 1)

Abschnitt betitelt „Auslastung — zeitraum-normierte Nutzung (#358 Phase 1)“
Auslastungs-Basis = Kapazität_brutto_kWh × Tage_im_Zeitraum
Auslastung [%] = Entladung_kWh ÷ Auslastungs-Basis × 100

SoT: core/berechnungen/speicher.py::auslastungs_basis_kwh + auslastung_prozent. Geliefert von aktueller_monat.py (Felder speicher_auslastungs_basis_kwh / speicher_auslastung_prozent), angezeigt in Cockpit → Monat und → Jahr.

Warum die Basis ein eigenes Feld ist: Auslastungen mehrerer Monate lassen sich nicht mitteln — ein Februar wiegt weniger als ein Juli, ein angefangener Monat noch weniger. Wer ein Jahr bildet, summiert Entladung und Basis und teilt einmal. Ein Prozent-Mittelwert wäre die Drift-Klasse, die diese Trennung von vornherein ausschließt (Beleg: test_speicher_kanon_symmetrie.py::test_auslastungs_basis_ist_additiv_ueber_monate — 40 % und 61 % ergeben zusammen 50 %, nicht 50,5 %).

Im laufenden Monat zählen nur die abgelaufenen Tage. Sonst stünde am 3. eines Monats eine Auslastung von 10 %, die nichts über den Speicher aussagt, sondern über das Datum — ein voller Nenner gegen einen angefangenen Zähler, genau der Fall aus KONZEPT-UNVOLLSTAENDIGE-WERTE §3.

Kein Deckel bei 100 %: zwei Zyklen an einem Tag sind real, und die Zahl soll das sagen dürfen. Ohne gepflegte Kapazität liefern beide Funktionen None — „unbekannt”, nicht 0.

Der Nutzen in Euro — Spread, nicht Voll-Strompreis

Abschnitt betitelt „Der Nutzen in Euro — Spread, nicht Voll-Strompreis“
PV-Anteil der Entladung = Entladung − min(Entladung, Netzladung × η)
Netz-Anteil der Entladung = min(Entladung, Netzladung × η)
Ersparnis = PV-Anteil × (Netzbezug − Einspeisevergütung) / 100
+ Netz-Anteil × (Netzbezug − Ladepreis) / 100

SoT: core/berechnungen/speicher_wirtschaftlichkeit.py::berechne_speicher_ersparnis. Aufrufer: T-Konto (aktueller_monat.py::_baue_investition_financial), Speicher-Dashboard und Sonstiges-Speicher (investitionen/dashboards.py), Aussichten. Gewächtert von test_speicher_kanon_symmetrie.py (drei Achsen, mit absoluten Erwartungen — Symmetrie allein ließe auch drei gleich falsche Zahlen durch, Lehre aus N-130).

Warum der Spread: die entladene kWh ersetzt Netzbezug, hätte aber sonst Einspeisevergütung erbracht. Die entgangene Vergütung ist eine reale Gegenposition (Drift-Audit A3, von Gernot am 2026-08-04 für #358 bestätigt).

Warum die Netzladung ausgenommen ist: sie hätte nie eingespeist werden können, der PV-Spread gilt für sie nicht. Ihr Vorteil ist Netzbezug − Ladepreis; ohne gepflegten Ladepreis ist sie kostenneutrale Durchleitung (z. B. Backup-Vorhaltung).

Zwei Fundstellen wichen bis zum 2026-08-04 ab — beide sichtbar: aktueller_monat.py rechnete Entladung × Netzbezug (bei 30/8 ct 36 % zu hoch), dashboards.py den Spread inline auf der gesamten Entladung und wies den Arbitrage-Gewinn zusätzlich aus — der Komponenten-Hub addiert beide Posten, netzgeladene Energie zählte damit doppelt. Die Formel im Layer zu haben genügt nicht; sie ist erst durchgesetzt, wenn keine Inline-Kopie mehr danebensteht (ADR-001).

Ein Speicher trägt zwei Kapazitäten, beide im Formular. Die Trennlinie läuft nicht zwischen „genau” und „ungefähr”, sondern zwischen zwei Fragen:

FrageKapazitätWarumStellen
Wie oft wurde der Speicher umgeschlagen?brutto (kapazitaet_kwh)Bezugsgröße der Hersteller-Garantie; ein Nenner, der am Pflegezustand hängt, macht dieselbe Anlage unvergleichbarVollzyklen (alle Sichten), graue Last, Community-Datensatz, Anzeige/Beschreibung der Komponente
Wie viel Energie geht durch den Speicher?netto (nutzbare_kapazitaet_kwh, still auf brutto zurückfallend)Simuliert bzw. prognostiziert wird eine durchgefahrene Menge — und durch den Speicher geht nur der nutzbare HubTages-Vorschau „Speicher voll um …” (Planungs-Tab und HA-Sensor eedc_speicher_voll_um), Wirtschaftlichkeits-Prognose ohne IST-Aggregat, η-SoC-Delta

Und seit N-238 (2026-08-12) gehört der Wirkungsgrad dazu. Die Tages-Vorschau rechnete den Ladeweg verlustfrei — für 12 kWh im Speicher hielt sie 12 kWh Überschuss für ausreichend, real sind es bei η = 95 % rund 12,7 und bei 85 % über 14. „Speicher voll um” lag deshalb systematisch vor der tatsächlichen Uhrzeit. Kapazität und Wirkungsgrad kommen jetzt aus einem Helper (aggregiere_speicher_basis: Kapazität summiert, Wirkungsgrad als Minimum — eine Kette ist nicht besser als ihr schwächstes Glied); der HA-Sensor und die Kachel lesen dieselbe Regel, statt sie zweimal zu bilden. Die Verluste sitzen ganz auf der Ladeseite, weil die übergebene Kapazität die abgabefähige Menge ist — sie zusätzlich beim Entladen anzusetzen wäre Doppelzählung (dieselbe Konvention wie core/berechnungen/speicher_sizing.py).

Die gepflegte Kapazität bleibt maßgeblich und wird NICHT durch die gemessene ersetzt (Gernots Entscheid 2026-08-12). Sie trägt eine Absicht: es gibt Anwender, die ihren Speicher bewusst nicht auf 100 % laden, und für die wäre eine Korrektur nach unten falsch — sie wollen wissen, wann ihr Ziel erreicht ist. Der Sizing-Block weist die gemessene Größe stattdessen daneben aus und trennt die zwei Ursachen einer Lücke (Ladegrenze ⟷ Ladeverlust) über den tatsächlich genutzten Ladestands-Bereich (messe_soc_nutzung).

Seit #379 (2026-08-15) trägt das Netto-Feld eine dritte Aussage: die Entlade-Untergrenze. Aus dem Verhältnis beider Kapazitäten leitet core/berechnungen/speicher_potential.py:: leer_schwelle_prozent ab, ab welchem Ladestand dieser Speicher nichts mehr abgibt — (1 − netto ÷ brutto) × 100, bei 24 von 30 kWh also 20 %. Vorher stand dort die feste Zahl 5 %, und das war ein Fehler mit Folgen: Wer eine eigene Untergrenze fährt, dessen Speicher erreicht 5 % nie, also lief er nie „leer”, also war das Nutzbare Zusatzpotential strukturell 0 — die Sicht behauptete „ein größerer Speicher hätte nichts gebracht”, obwohl sie es gar nicht messen konnte.

Drei Festlegungen gehören dazu, jede aus einer Messung:

  • Aufschlag von 3 Prozentpunkten (SOC_LEER_TOLERANZ_PP). Der Melder hat 20 % eingestellt, seine Kurve dreht bei 21 % — Wechselrichter schalten mit Puffer ab, der SoC kommt in ganzen Prozent, und diese Auswertung liest Stundenmittel. Ohne Aufschlag träfe die neue Schwelle so zuverlässig daneben wie die alte.
  • Deckel bei 50 % (SOC_LEER_MAX_PROZENT) gegen die N-235-Klasse: „1 kWh nutzbar von 30” ergäbe sonst 96,7 % und ließe den Speicher fast immer als leer gelten — die Kennzahl fiele zu hoch aus, also in die Gegenrichtung der Deckelung.
  • Die Reserve sitzt unten. Bei Heimspeichern der Normalfall; die Oberseite deckt SOC_VOLL_PROZENT ab, und eine gewollte Lade-Grenze erkennt messe_soc_nutzung an den Daten.

Wer nichts pflegt, sieht keine geänderte Zahl — Rückfall auf 5 %, Abnahmekriterium des Baus. Umgekehrt heißt das: Die Korrektur erreicht nur Anlagen mit gepflegter nutzbarer Kapazität.

Und die Umkehrung gehört dazu (N-254): Ohne gepflegte nutzbare Kapazität ist „der Speicher wurde nie leer” zweideutig — er kann groß genug sein (dann hätte mehr Kapazität nichts gebracht) oder an einer Untergrenze hängen (dann hätte sie geholfen). Zwei Ursachen, entgegengesetzte Antworten, und eedc kann sie ohne die Pflege nicht trennen. boden_nie_erreicht(soc_min, schwelle, schwelle_ist_abgeleitet) erkennt genau diesen Zustand; die Sicht sagt dann „nicht beurteilbar” und die Kachel zeigt „—” statt 0 — dieselbe Doktrin wie „nicht gemessen statt 0”.

Die Regel hängt an der Pflege, nicht am Abstand allein — sonst verlöre auch der Fall seine Aussage, für den die Kennzahl gebaut wurde (Dev-Anlage, nie unter 31 %). Ist die Schwelle abgeleitet, kennt eedc die Untergrenze, und ein Speicher, der sie nicht erreicht, war wirklich groß genug. Wer seinen Boden real berührt (Winter, 6 %), bekommt die klare Aussage ebenfalls.

Die Schwelle gilt im ganzen Speicher-Hub, nicht nur in Phase 2. speicher_sizing.py definierte 95.0/5.0 bis dahin selbst — mit dem Kommentar „dieselben Schwellen wie in speicher_potential.py”. Die Absicht war formuliert, die Kopplung fehlte; ein ==-Test hätte nie angeschlagen, weil beide Werte gleich waren. Jetzt Import statt Kopie, geprüft an der Quelle.

SoT netto (seit A31-2): core/investition_kennwerte.py::get_speicher_nutzbare_kapazitaet_kwh — netto, sonst brutto, sonst None. Der Brutto-Fallback ist still (Entscheidung E17): kein Hinweis, keine Kennzeichnung, kein P4-Fall. Der Brutto-Wert ist nicht unvollständig, er ist die andere gültige Lesart derselben Größe; die Zahlenänderung aus A31-2 trifft deshalb ausschließlich Anlagen, die das optionale Feld bewusst gepflegt haben. Die Leserichtung geht nur netto → brutto und nie zurück — ein Brutto-Helper mit Netto-Fallback wäre genau die Verwechslung, die die Vollzyklen wieder vom Pflegezustand abhängig machte.

Nebenwirkung der Vorschau, die dazugehört: dieselbe Simulation liefert auch Einspeisung, Eigenverbrauch und Autarkie des Vorschautags. Ein kleinerer Puffer nimmt weniger Überschuss auf — mehr geht ins Netz, weniger bleibt im Haus. Gemessen an der Demo-Anlage (15,4 kWh brutto gegen 13,9 kWh netto, 28.07.–02.08.2026): Einspeisung +0,75 bis +1,08 kWh/Tag, Eigenverbrauch entsprechend niedriger, Autarkie −1,7 bis −3,2 Prozentpunkte, „Speicher voll” an einem der sechs Tage eine Stunde früher (die Stundenauflösung verschluckt den Effekt an den übrigen).

Woher die Kapazität kommt (SoT seit A31-1): core/investition_kennwerte.py::get_speicher_kapazitaet_kwh — brutto (kapazitaet_kwh), nur aus dem parameter-JSON, ohne Default. Ist nichts gepflegt, liefert er None, und der Aufrufer entscheidet (summieren mit or 0, Zahl unterdrücken, Rechnung auslassen); eine Zahl erfindet er nicht (Entscheidung E16, ADR-002/P3-a). Bis dahin stand an drei Stellen ein .get(…, 10): ein Speicher ohne gepflegte Kapazität bekam still 10 kWh und daraus Vollzyklen und eine Jahres-Ersparnis (N127). Der fehlende Wert wird stattdessen ausgewiesen — Daten-Checker („Kapazität (kWh) fehlt”) und die Antwort selbst (kapazitaet_fehlt + Hinweis, P4).

Abgrenzung „SoC-Hübe” (TagesZusammenfassung.batterie_vollzyklen = ΣΔSoC ÷ 200): eine andere Kennzahl, die reale Lade-Hübe misst und damit als einzige eine 10/90-Fahrweise abbildet (ein voller Hub = 160 pp = 0,8). Sie ist ein Bestandsmaß, hängt an einem SoC-Sensor und ist kein Ersatz für die Vollzyklen. Sichtbar in der Energieprofil-Tagestabelle unter diesem Namen.

Historie: Bis 2026-07-28 rechneten vier von fünf Stellen mit der Ladung und nur der HA-Sensor mit der Entladung; die Tages-Kachel zeigte sogar die ΔSoC-Größe unter dem Namen „Vollzyklen”. Auf derselben Anlage standen dadurch Zahlen, die um den Speicher-Wirkungsgrad auseinanderlagen (gemessen 10,97 gegen 8,57 bei η 78 %). Kein Test hat das bemerkt — daher jetzt der Symmetrie-Test über alle Pfade.

eedc kennt und braucht keinen Ziel-SOC: Vollzyklen und Wirkungsgrad kommen aus gemessenen Lade-/Entlademengen. Eine Annahme steckt nur in der Wirtschaftlichkeits-Prognose (250 Vollzyklen × Brutto-Kapazität, SPEICHER_ZYKLEN_PRO_JAHR), die vor dem Vorliegen von Messdaten greift, sowie in der Tages-Vorschau („Speicher voll um …”, core/berechnungen/speicher_simulation.py), die von 0 bis 100 % der Brutto-Kapazität simuliert — wer bei 90 % abriegelt, ist real früher voll. Beides ist mit v4.0.2 erledigt: Kapazitäts-SoT get_speicher_kapazitaet_kwh (52d3714b) und die Netto-Umstellung von Tagesvorschau und Wirtschaftlichkeits-Prognose (c5c4437c), baumweit gewächtert (5dc3f488, ADR-002 P3-a). Die Vollzyklen bleiben bewusst brutto (Kanon f1644cc8) — die Netto-Umstellung zieht sie nicht mit.

Round-Trip-Wirkungsgrad (η) — SoC-korrigiert, nicht roh

Abschnitt betitelt „Round-Trip-Wirkungsgrad (η) — SoC-korrigiert, nicht roh“

Funktion: services/speicher_wirtschaftlichkeit.py::berechne_ist_wirkungsgrad Verwendet in: Cockpit → Monat, Komponenten-Hub, ROI-Analyse

η ist kein einfacher Quotient über einen Zeitraum. Ladung und Entladung sind Flüsse, der Speicher ist ein Bestand: Was am Periodenende im Akku steht, wird erst danach entladen. Über eine Monatsgrenze verrutscht dadurch Energie, und der rohe Quotient zappelt — er kann 100 % überschreiten oder zu niedrig ausfallen.

Zwei Pfade, in dieser Reihenfolge:

LageRechnungquelle
Fenster ≥ WIRKUNGSGRAD_FENSTER_MONATE_MIN (6)entladung ÷ ladung — ΔSoC mittelt sich ausfenster_lang
Kurzes Fenster, SoC am Rand bekannt(entladung + ΔSoC_kwh) ÷ ladung, geklemmt auf 0…100 %soc_korrigiert
Kurzes Fenster, kein SoCroher Quotient, nur wenn ≤ 100 %roh-unkorrigiert
sonstkein Wert, Grund steht an der Kachel (P4)fenster-zu-kurz

Warum der rohe Wert nicht einfach verworfen wird: Die meisten Anlagen haben keinen SoC-Sensor. Ihn zu unterdrücken hieße, für sie dauerhaft „—” anzuzeigen — P4 verlangt „sagen, was man weiß und wie sicher”, nicht Schweigen. Deshalb wird er ausgewiesen und gekennzeichnet. Unterdrückt wird nur, was nachweislich falsch ist: über 100 % kann kein Speicher.

Der Layer-SoT speicher_effizienz_prozent klemmt bewusst NICHT („Diagnose statt stillem Cap”). Die Diagnose ist der Daten-Checker: meldet ein Speicher kumulativ mehr Entladung als Ladung, ist das kein Carry-over mehr, sondern ein Erfassungsfehler — meist ladung_kwh als reine PV-Ladung gepflegt (#281). ladung_netz_kwhladung_kwh ist Vertrag, kein zweiter Posten.

Historie (F-22, rapahl 2026-05-22 und 2026-08-08): Bis v4.0.11 stand in aktueller_monat.py ein Alles-oder-Nichts-Schalter auf |ΔSoC| > 20 pp — über der Schwelle wurde ausgeblendet, darunter der rohe Wert gezeigt; korrigiert wurde nie, und ohne SoC-Randwerte ging er ungeprüft hinaus. Gemessen an der Demo-Anlage: 2025-11 „—” statt 81,6 %, 2025-10 roh 83,1 statt 82,4 %. Die korrigierende Funktion existierte seit #264 und erreichte 2 von 12 Sichten — dieselbe Klasse wie F-16.

Funktion: berechne_eauto_einsparung() in core/calculations.py Verwendet in: ROI-Dashboard (investitionen.py)

FeldQuelle
jahresfahrleistung_kmInvestition.parameter (Default: 15000)
verbrauch_kwh_100kmInvestition.parameter (Default: 18)
pv_ladeanteil_prozentInvestition.parameter (Default: 60)
benzinpreis_euroInvestition.parameter (Default: 1,65)
vergleich_verbrauch_l_100kmInvestition.parameter (Default: 7,5) — der fiktive Vergleichs-Benziner
v2h_faehigInvestition.parameter (Default: false)
eigener_verbrauch_l_100kmInvestition.parameterkein Default, s. „Plug-in-Hybrid” unten
elektrischer_fahranteil_prozentInvestition.parameterkein Default

Diese Tabelle nannte bis 2026-08-08 vier Schlüssel, die eedc nicht kennt (km_jahr, pv_anteil_prozent, benzin_verbrauch_liter_100km, nutzt_v2h) und zwei falsche Defaults (1,85 € statt 1,65 €; 7,0 L statt 7,5 L). Es sind genau die Legacy-Keys, deren Lesen v3.25.0 im Code als Bugs #1–#4 korrigiert hat — die Doku ist damals nicht mitgezogen. Historie: LEGACY_PARAM_KEYS in core/investition_parameter.py führt die Zuordnung alt → neu.

km_elektrisch = jahresfahrleistung_km (BEV — Normalfall)
= jahresfahrleistung_km * Fahranteil / 100 (Plug-in-Hybrid, s. u.)
km_verbrenner = jahresfahrleistung_km - km_elektrisch
Strom_Bedarf = km_elektrisch * Verbrauch_kWh_100km / 100
PV_Anteil = pv_ladeanteil_prozent / 100
Netz_Anteil = 1 - PV_Anteil
Strom_Kosten = Strom_Bedarf * Netz_Anteil * Strompreis / 100
Benzin_Verbrauch = jahresfahrleistung_km * Vergleich_L_100km / 100 ← ALLE Kilometer
Benzin_Kosten = Benzin_Verbrauch * Benzinpreis_EUR
Fossile_Kosten = km_verbrenner / 100 * Eigener_L_100km * Benzinpreis_EUR
V2H_Einsparung = V2H_Entladung_kWh * V2H_Preis / 100 (wenn V2H aktiv)
V2H_Preis = v2h_entlade_preis_cent (Override, optional)
= Bezugspreis - Einspeisevergütung (Normalfall, aus den gepflegten Tarifen)
Jahres-Einsparung = Benzin_Kosten - Strom_Kosten - Fossile_Kosten + V2H_Einsparung
CO2_Verbrenner = Benzin_Verbrauch * 2.37
CO2_E-Auto = Strom_Bedarf * Netz_Anteil * 0.38
CO2_Fossil = km_verbrenner / 100 * Eigener_L_100km * 2.37
CO2-Einsparung = CO2_Verbrenner - CO2_E-Auto - CO2_Fossil

Plug-in-Hybrid: elektrischer und fossiler Anteil (ab #331)

Abschnitt betitelt „Plug-in-Hybrid: elektrischer und fossiler Anteil (ab #331)“

Ein Plug-in-Hybrid fährt einen Teil seiner Kilometer mit Kraftstoff. eedc unterstellte bis dahin 100 % elektrisch — Ersparnis und CO₂-Bilanz fielen dadurch zu gut aus.

Es gibt keinen Fahrzeugtyp und keinen Schalter: das gepflegte Feld ist die Aussage. Ist eigener_verbrauch_l_100km gesetzt, hat das Fahrzeug einen Verbrenner; ist es leer, bleibt jede Zahl exakt wie vorher. Bestandsfahrzeuge tragen das Feld nicht, für sie ändert sich nichts.

Wie der elektrische Anteil bestimmt wird (SoT core/berechnungen/phev_anteil.py, dieselbe Funktion für IST und Prognose):

WegBedingungRechnung
gemessenmonatlicher Fahrverbrauch (kWh) erfasstkm_elektrisch = min(km_gefahren, Fahrverbrauch / Verbrauch_kWh_100km × 100)
geschätztnur elektrischer_fahranteil_prozent gepflegtkm_elektrisch = km_gefahren × Anteil / 100
unbestimmtkeines von beidenkm_elektrisch = km_gefahren (Verhalten wie vorher) — der Daten-Checker meldet es

Die Deckelung auf die gefahrenen Kilometer ist nicht kosmetisch. Ist der Kennwert zu niedrig gepflegt oder der Zähler zu großzügig, käme rechnerisch mehr elektrische Strecke heraus als gefahren wurde — und damit negative Verbrenner-Kilometer, also eine Ersparnis größer als die Wahrheit.

Zwei Verbräuche, zwei Bedeutungen: vergleich_verbrauch_l_100km beschreibt weiterhin den fiktiven Vergleichs-Benziner, und die Frage „was hätte ein Benziner gekostet” bleibt über alle Kilometer gestellt — sonst verglichen wir ein Auto mit einem halben. eigener_verbrauch_l_100km ist der real getankte Verbrauch und erzeugt eine eigene Kostenposition daneben. Die geladene Energie wird nicht zusätzlich skaliert: sie ist im IST gemessen, ein Hybrid lädt ohnehin weniger.

In der Finanz-Prognose (Auswertungen → Finanzen, Backend aussichten.py) wird die E-Auto-Ersparnis pro Monat mit dem echten Kraftstoffpreis berechnet:

Für jeden historischen Monat:
Benzinpreis = Monatsdaten.kraftstoffpreis_euro (wenn vorhanden)
∨ Investition.parameter.benzinpreis_euro (Fallback statisch)
Benzin_Kosten_Monat = km_gefahren / 100 * Vergleich_L_100km * Benzinpreis
Strom_Kosten_Monat = ladung_netz_kwh * Netzbezug_Preis / 100 # kanonische Heimladungs-Quelle (Wallbox bzw. E-Auto), s. §3.2
Für Jahresprognose:
Prognose_Benzinpreis = Ø(Monatsdaten.kraftstoffpreis_euro) (historischer Durchschnitt)
∨ Investition.parameter.benzinpreis_euro (Fallback)

Datenquelle: EU Weekly Oil Bulletin (Euro-Super 95, inkl. Steuern, wöchentlich, History seit 2005). Befüllung durch den Scheduler-Job (täglich 06:00 + Startlauf), den Reparatur-Pfad „Kraftstoffpreise nachpflegen” oder den Backfill-Endpoint. Fehlt der Monatswert, rechnet die Kette mit dem Investitions-Parameter bzw. 1,65 €/L weiter — der Daten-Checker meldet offene Monate deshalb als eigene Kategorie.

Betroffen: Aussichten (aussichten.py), HA-Sensor-Export (ha_export.py), PDF-Finanzbericht (pdf_operations.py).

PV-Anteil der Heimladung: gemessen, sonst abgeleitet (ab 2026-08-08, N-141)

Abschnitt betitelt „PV-Anteil der Heimladung: gemessen, sonst abgeleitet (ab 2026-08-08, N-141)“

Die Formeln oben beschreiben die Prognose-Achse, die den von Hand gepflegten pv_ladeanteil_prozent liest — und, wo er fehlt, seit 2026-08-08 den IST-Anteil (s. unten). Auf der IST-Achse gibt es diesen Parameter nicht — dort steht je Monat das erfasste Paar ladung_pv_kwh / ladung_netz_kwh. Und genau das fehlte den meisten Anlagen: eine Wallbox misst ihren PV-Anteil nicht, sie zählt nur Kilowattstunden. Ohne evcc oder einen eigenen Zähler lieferte get_emob_pv_netz_kwh deshalb (0, Gesamtladung) — die gesamte Heimladung galt als Netzstrom.

Reihenfolge (SoT core/berechnungen/pv_anteil_ladung.py):

  1. Ein erfasster Wert gewinnt immer — auch eine gepflegte 0. Geprüft wird die Anwesenheit des Schlüssels ladung_pv_kwh, nicht seine Größe: „diesen Monat kam nichts aus der Sonne” ist eine Aussage, keine Lücke.

  2. Sonst leitet eedc den Anteil aus den eigenen Stundenwerten ab, Regel Einspeise-Deckung:

    je Stunde: ungedeckt = max(0, Ladung − Netzbezug − Speicherentladung)
    PV = min(Ladung, ungedeckt + Einspeisung)
    Netz = Ladung − PV

    Der zweite Summand fängt die Unschärfe der Stundenmittelung auf: Was in derselben Stunde eingespeist wurde, hätte stattdessen laden können.

  3. Angewandt wird der Anteil, nicht die Kilowattstunde. Der abgeleitete Prozentsatz geht auf die kanonische Monatsladung — nur so bleibt Ladung == PV + Netz exakt geschlossen, auch wenn die Tagesspur eine andere Menge kennt als die Monatszeile.

Wo die Regel angewandt wird — eine Schicht unter dem Pool (services/emob_ladeanteil.py, seit 2026-08-08, Fund F-16): die Monatszeilen werden angereichert, bevor irgendjemand sie zu einer Heimladung poolt. Das ist keine Feinheit, sondern der Unterschied zwischen vier und achtzehn Sichten: liegt die Ableitung über get_emob_heimladung_canonical, trifft sie nur die Felder EmobFakten.ladung_pv_kwh/ladung_netz_kwh — jede Sicht, die die mitgereichten Rohzeilen selbst poolt (Cockpit → Jahr, Jahresbericht-PDF) oder InvestitionMonatsdaten direkt liest (Komponenten-Hub, Aussichten, HA-Export), zeigt daneben weiter 0 %. Sichten der zweiten Gruppe holen dieselbe Anreicherung über reichere_monatszeilen_an.

Zwei Ausnahmen, beide bewusst:

  • Der Community-Payload liest eauto_summe_gemessen/wallbox_summe_gemessen — der Server hat die Rohdaten nie gesehen und rechnet nichts nach; eine Schätzung wäre in einem Benchmark nicht mehr als solche erkennbar (dieselbe Linie wie beim BKW-Eigenverbrauch).
  • Schreib-, Import- und Checker-Pfade bleiben außen vor. Die Anreicherung geschieht zur Lesezeit auf Kopien; programmatisch in verbrauch_daten zu schreiben bleibt verboten.

Die Prognose-Achse rechnet mit derselben Zahl (N-188, seit 2026-08-08): fehlt am Fahrzeug der gepflegte pv_ladeanteil_prozent, nimmt die ROI-Prognose den IST-Anteil über monats_fakten.ist_pv_ladeanteil_prozent (Σ PV ÷ Σ Ladung, ladungsgewichtet) statt des früheren Vorgabewerts von 60 %. Der Default greift nur noch, wenn auch das IST keine Heimladung kennt. Die zweite Prognose-Quelle (aussichten.py, leitet ihre Quote aus der Historie ab) zieht über dieselbe Anreicherung mit.

Herkunft: EmobFakten.ladung_anteil_abgeleitet sagt, ob die Aufteilung gerechnet ist; auf der Tagesebene trägt source_provenance die Marke einspeise_deckung bzw. einspeise_deckung_teilweise (letztere, wenn nicht jede Ladestunde auswertbar war — dann ist der Wert eine Teilsumme, P4).

Vermessen gegen evcc (2026-08-08, Anlage 1, Feb–Aug 2026, 963 kWh Heimladung, Referenz sensor.evcc_helper_pv/net_charged_kwh):

RegelPV-AnteilAbweichung zu evcc (67,9 %)
netzbasiert min(Ladung, Netzbezug)73,8 %+5,9 pp
netz + Speicherentladung60,8 %−7,2 pp
Einspeise-Deckung (gebaut)64,7 %−3,2 pp

Keine rückwirkende Berechnung. Der Wert entsteht beim Aggregieren eines Tages; Zeiträume vor diesem Feature tragen NULL, und NULL heißt „keine Aussage”, nicht „keine Sonne”.

Die Auflösung begrenzt die Genauigkeit. Meldet ein Wallbox-Zähler nur ganze Kilowattstunden (an der Referenzanlage 218 von 218 Stunden-Deltas ganzzahlig), trifft keine Rechnung die einzelne Stunde — über den Monat ist die Ableitung brauchbar, über die Stunde nicht.

Korrektur (2026-08-08): hier stand bis dahin „Offen: die Prognose zieht noch nicht mit (N-188)”. Das ist seit demselben Tag erledigt und vier Absätze weiter oben beschrieben — der Satz stammte aus dem Paket davor und ist beim Nachziehen stehen geblieben. Ein Dokument, das an zwei Stellen dasselbe verschieden behauptet, ist schlechter als eines, das schweigt.

Welcher Strompreis die Ladung bewertet (F-18 · ADR-002/P8)

Abschnitt betitelt „Welcher Strompreis die Ladung bewertet (F-18 · ADR-002/P8)“

Die Netzladung eines E-Autos wird mit dem Tarif bewertet, der im jeweiligen Monat galt — nicht mit dem heutigen. SoT ist services/eauto_wirtschaftlichkeit.aufgeloester_strompreis_cent, gerufen aus berechne_eauto_ersparnis_periode.

Bis 2026-08-08 löste jede Sicht diese eine Größe selbst auf, und zwar auf vier Arten:

Sichtvorherjetzt
Cockpit → Jahrheutiger Wallbox-TarifMonatstarif inkl. Flex-Ø
HA-Export (Anlagen-Sensoren)heutiger allgemeiner TarifMonats-Wallbox-Tarif
HA-Export (Fahrzeug-Sensor)heutiger allgemeiner TarifMonats-Wallbox-Tarif
Komponenten-Hubmengengewichteter Monats-Øunverändert (jetzt über den SoT)
Auswertungen → AussichtenMonatstarif, aber allgemeinMonats-Wallbox-Tarif inkl. Flex-Ø

Gewichtet wird nach der Netzladung je Monat, nicht nach Kilometern: der Preis bepreist Kilowattstunden. Nur wo eine Monatsaufteilung der Netzladung nicht existiert — bei reinem PV-Laden — fällt die Mittelung auf die km zurück, den Schlüssel, nach dem der Wallbox-Pool ohnehin attribuiert.

Für eine Anlage ohne Tarifwechsel bewegt sich nichts. Der mengengewichtete Ø eines einzigen Tarifs ist dieser Tarif. Wer den Tarif gewechselt hat, sieht dagegen vier HA-Sensoren einmalig springen (e_auto_ersparnis_vs_benzin_euro, netto_ertrag_euro, roi_prozent, amortisation_jahre) — an einer vermessenen Anlage um −41,10 €.

Der P8-Wächter deckt diese Fläche nicht ab. Alle vier beteiligten Dateien stehen in P8_BASELINE_AUSNAHMEN, weil sie den heutigen Tarif für die Hochrechnung nach vorn auch brauchen. Gesichert wird die Preisachse deshalb durch einen eigenen Wächter (test_wurzelmuster_konformitaet.py::test_p8_emob_ersparnis_bekommt_die_monatspreise, baumweit) und den Sichten-Vergleich test_emob_preisachse_sichten_symmetrie.py.

Trägt eine Wallbox die Ladeenergie, ist sie die Quelle — auch ohne gesetzte Zuordnung (parent_investition_id). Diese Regel gilt seit 2026-08-08 in allen drei Pfaden:

PfadOrt
Monatsebeneeauto_wirtschaftlichkeit.get_emob_heimladung_canonical
Tages-Leistungspfadservices/live_sensor_config.py (#356)
Tages-Zählerpfadsnapshot/komponenten_beitraege.wallbox_deckt_ladung_ab

Vorher kannte der Zählerpfad nur die Zuordnung; ein E-Auto mit eigenem kWh-Zähler ohne Parent lief an ihr vorbei, und derselbe Ladevorgang stand zweimal im Tagesverlauf. Bereits gespeicherte Tage bleiben davon unberührt — dafür meldet der Daten-Checker sie (Kategorie emob_doppelzaehlung_tage) und bietet „Zeitraum neu aggregieren” an. Bewusst kein Start-Migrationslauf: die Heilung überschreibt Messwerte und bleibt eine Entscheidung des Anwenders.

Funktion: berechne_waermepumpe_einsparung() in core/calculations.py Verwendet in: ROI-Dashboard (investitionen.py)

Modus A: gesamt_jaz (Standard - gemessene Jahresarbeitszahl)

WP_Strom_kWh = Gesamtwärmebedarf / JAZ

Modus B: scop (EU-Label SCOP-Werte)

Strom_Heizung = Heizwärmebedarf / SCOP_Heizung
Strom_Warmwasser = Warmwasserbedarf / SCOP_Warmwasser
WP_Strom_kWh = Strom_Heizung + Strom_Warmwasser

Modus C: getrennte_cops (präzise Betriebspunkte)

Strom_Heizung = Heizwärmebedarf / COP_Heizung
Strom_Warmwasser = Warmwasserbedarf / COP_Warmwasser
WP_Strom_kWh = Strom_Heizung + Strom_Warmwasser
PV_Anteil = pv_anteil_prozent / 100
Netz_Anteil = 1 - PV_Anteil
η_alt = alter_wirkungsgrad(Energieträger) # 0,90 Gas · 0,85 Öl · 1,0 Strom
WP_Kosten = WP_Strom * Netz_Anteil * Strompreis / 100
Alte_Kosten = Gesamtwärmebedarf / η_alt * Alter_Preis / 100
+ alternativ_zusatzkosten_jahr # Schornsteinfeger / Wartung / Grundpreis Gaszähler
Jahres-Einsparung = Alte_Kosten - WP_Kosten
CO2_alt = Gesamtwärmebedarf / η_alt * CO2_Faktor[gas|oel|strom]
CO2_WP = WP_Strom * Netz_Anteil * 0.38
CO2-Einsparung = CO2_alt - CO2_WP

Wirkungsgrad der Altanlage (η_alt): Gesamtwärmebedarf ist abgegebene Wärme, nicht Brennstoff — das Eingabefeld heißt „Heizwärmebedarf (kWh/Jahr) — aus Energieausweis”, und derselbe Wert wird oben durch die JAZ geteilt (JAZ = Wärme/Strom). Ein Kessel muss dafür Wärme / η verfeuern; Alter_Preis ist der Preis je kWh Brennstoff (so steht er auf der Rechnung). Die Umrechnung macht der Layer-SoT gas_kosten_altanlage, die η-Wahl der Resolver alter_wirkungsgrad — beide in core/berechnungen/alternativkosten.py.

Bis v4.0.1 fehlte die η-Rückrechnung in diesem Pfad: die ROI-Seite wies für dieselbe Wärmepumpe eine niedrigere Ersparnis (und CO₂-Einsparung) aus als Aussichten, HA-Export und WP-Dashboard, die alle über gas_kosten_altanlage laufen. Die fixen Zusatzkosten werden nicht durch η geteilt — sie sind keine Energie.

Strom-Direktheizung: η = 1,0. Eine Widerstandsheizung (Nachtspeicher, Infrarot) setzt Strom verlustfrei in Wärme um; ihr einen Kesselverlust anzurechnen, würde die WP-Ersparnis überhöhen. Die η-Wahl lag vorher an vier Stellen dupliziert vor und kannte diesen Fall nirgends.

Diese Rechnung braucht zwei gepflegte Angaben — sonst läuft sie nicht.Bis 2026-08-16 stand hier das Gegenteil: „Split-Klimaanlagen (wp_art = luft_luft) durchlaufen diese Rechnung gar nicht … ein Luft-Luft-Gerät ersetzt in aller Regel keine Heizung.” Diese Prämisse ist gefallen — eine Luft-Luft-Wärmepumpe kann sehr wohl eine Gasheizung ersetzen, und viele Anwender heizen damit. Der echte Defekt war nie die Bauart, sondern eine erfundene Eingabe: Weil Heizwärmebedarf/Warmwasserbedarf Defaults hatten (12.000/3.000 kWh), kam die Formel nie ohne Ergebnis heraus — bei den übrigen Standardwerten rund 1.100 €/Jahr und 2.210 kg CO₂/Jahr Ersparnis gegen eine Gasheizung, die es nie gab, inklusive Beitrag zu den Anlagen-Summen. Heute gilt stattdessen: (1) Ist beim ersetzten Energieträger „Nichts ersetzt (Neubau)” gewählt, wird weder Ersparnis noch CO₂-Ersparnis konstruiert — für jede Wärmepumpenart, nicht nur für Klimaanlagen; das war der Neubau-Fall, der bis dahin still eine Gaskessel-Ersparnis bekam. (2) Ist kein Wärmebedarf gepflegt, gibt es keinen Default mehr; die ROI-Zeile trägt „—” und nicht_bewertet samt Begründung. Eine halb gepflegte Angabe zählt entsprechend halb (nur Warmwasser gepflegt ⇒ Heizwärme 0). (3) Bestandsschutz ohne Migration: Steht an einer Luft-Luft-WP noch exakt die alte Vorbelegung 12.000/3.000, zählt sie als offene Frage statt als Antwort — sie war seit v4.0.6 unsichtbar und damit nicht korrigierbar. Bei klassischen Wärmepumpen bleibt sie eine Schätzung und wird gerechnet. Die gemessenen Pfade (services/wp_wirtschaftlichkeit.py, co2_wp_ersparnis_kg, Aussichten, JAZ/COP) haben zusätzlich weiterhin ihren wp_waerme_kwh <= 0-Wächter. SoT der Unterscheidung: core/berechnungen/alternativkosten.py::ersetzt_keine_heizung; die Bauart-Unterscheidung ist_luft_luft_waermepumpe trägt nur noch den Bestandsschutz aus (3).

Punkt (1) galt bis 2026-08-29 nur je Gerät, nicht in den anlagenweiten Sichten — und der häufigste Fall fiel genau durch diese Lücke. Wer neben einer Wärmepumpe, die eine Gasheizung ersetzt hat, eine Split-Klimaanlage betreibt (in eedc ebenfalls eine Wärmepumpe, meist mit „Nichts ersetzt (Neubau)”), bekam auch deren Wärme als vermiedenes Gas gutgeschrieben: Die Sperre griff erst, wenn keine einzige Wärmepumpe etwas ersetzt hatte. Betroffen waren die CO₂-Ersparnis im Jahresbericht — bei zwei gleich großen Geräten war sie doppelt so hoch (2.458 statt 1.229 kg/Jahr, an einer nachgestellten Anlage gemessen) — und die Alternativkosten-Ersparnis in der Jahresprognose unter Auswertungen → Aussichten, dort mit umgekehrtem Vorzeichen: Der Strom des Neubau-Geräts wurde von der Ersparnis der ersetzenden Wärmepumpe abgezogen (1.057 → 704 €/Jahr, ebenfalls nachgestellt).

Heute gilt Punkt (1) überall. Der fossile Vergleich rechnet auf beiden Seiten mit den Geräten, die etwas ersetzt haben — dieselbe Grundmenge im Zähler wie im Abzug. Die Mengen der Anlage (Stromverbrauch, gelieferte Wärme) bleiben davon unberührt; kleiner wird allein die Grundmenge des Vergleichs. SoT sind die Teilmengen WpFakten.waerme_mit_ersatz_kwh / strom_mit_ersatz_kwh aus der Monats-Fakten-Schicht (ADR-002/P10).

Warum Teilsummen und keine Sperre. Die Arbeitszahl wird bei gemischten Bauarten gesperrt, weil ein Quotient über zwei Maßstäbe keine Aussage hat. Eine CO₂-Menge dagegen ist additiv — für sie genügt die Teilsumme. Kennzahlen trennen wir je Bauart, Mengen summieren wir.

Alternativ-Zusatzkosten (v3.21.0, #141): alternativ_zusatzkosten_jahr (€/Jahr) deckt laufende Fixkosten der Alt-Heizung (Schornsteinfeger, Wartung, Gaszähler-Grundpreis) ab. Wird in fünf Berechnungs-Pfaden berücksichtigt: Aussichten historisch + Prognose, HA-Sensor-Export inkl. WP-Sensor, PDF-Jahresbericht, Investitions-Vorschau. In historischen Aggregaten anteilig pro erfasstem Monat (alternativ_zusatzkosten_jahr / 12).

FeldParameter-KeyDefault
Effizienz-Moduseffizienz_modusgesamt_jaz
JAZjaz3.5
SCOP Heizungscop_heizung4.5
SCOP Warmwasserscop_warmwasser3.2
COP Heizungcop_heizung3.9
COP Warmwassercop_warmwasser3.0
Heizwärmebedarfheizwaermebedarf_kwhkein Default mehr in der ROI-Rechnung; im Formular Vorbelegung 12000, bei luft_luft nicht vorbelegt (Feld seit 2026-08-16 wieder angeboten)
Warmwasserbedarfwarmwasserbedarf_kwhkein Default mehr in der ROI-Rechnung; im Formular Vorbelegung 3000, bei luft_luft nicht vorbelegt (Feld seit 2026-08-16 wieder angeboten)
PV-Anteilpv_anteil_prozent30
Alter Energieträgeralter_energietraegergas
Alter Preisalter_preis_cent_kwh12 (Fallback wenn Monatsdaten.gaspreis_cent_kwh leer)
Alternativ-Zusatzkostenalternativ_zusatzkosten_jahr0 (€/Jahr)
WP-StrompreisSpezialtarif waermepumpeFallback: allgemein

3.5b Modus-Split: Heizen und Kühlen trennen (#263 K-2)

Abschnitt betitelt „3.5b Modus-Split: Heizen und Kühlen trennen (#263 K-2)“

Eine Split-Klimaanlage heizt und kühlt über denselben Zähler. Die Aufteilung ist aus keinem gespeicherten Wert rekonstruierbar — sie entsteht nur, wenn eedc den Betriebsmodus zur Messzeit mitschreibt (TagesEnergieProfil.betriebsmodus_je_wp, seit S2).

Welcher Wert die Stunde bestimmt (core/betriebsmodus.py::normalisiere_betriebsmodus): der eingestellte Modus (hvac_mode), verfeinert durch den Ist-Betrieb (hvac_action), wo die Integration ihn liefert (D2). ⚠ Verfeinern heißt nicht verwerfen — der Ist-Betrieb schlägt den Modus nur, wo er eine Richtung nennt (heating/cooling/drying/fan) oder off. idle (Leerlauf) nennt keine und fällt deshalb auf den eingestellten Modus zurück (#399, 28.08.2026; davor ergab es unbestimmt, und bei einem taktenden Inverter-Gerät fiel damit der Großteil des Stroms in nicht aufgeteilt). Ohne Richtung im Modus — Automatik (heat_cool/auto) oder gar kein Modus — bleibt es unbestimmt; geraten wird nicht.

je Tag d, je Gerät i:
roh_h = |komponenten["waermepumpe_<i>"]| # Leistungspfad, NEGATIV gespeichert
faktor_d = zaehler_kwh_d / Σ_h roh_h # nur wenn Zählersumme vorhanden
kwh[modus] += roh_h × faktor_d # Stunden OHNE Modus: nur in den Nenner
je Monat (ein Gerät):
modus_strom_heizen_kwh = Σ_d kwh[heizen]
modus_strom_kuehlen_kwh = Σ_d kwh[kuehlen]
modus_abdeckung_h = Σ_d Stunden mit Modus-Signal
nicht_aufgeteilt = Bezug − heizen − kuehlen # NIE gespeichert
anlagenweit (mehrere Geräte, gleicher Zeitraum):
modus_strom_*_kwh = Σ_i je Gerät # Mengen: addieren
modus_abdeckung_h = max_i je Gerät # Zeit: NICHT addieren

SoT: core/berechnungen/modus_split.py (rein) · services/energie_profil/modus_split_monat.py (Lader) · …/modus_split_schreiben.py (Schreiber, Schritt 4 des Monatsabschlusses).

Teilmengen, keine Summanden. stromverbrauch_kwh bleibt die einzige Bilanzgröße; die zwei Anteile werden ausgewiesen und nie addiert (Präzedenz ladung_pv_kwh bei der Wallbox). Wächter: test_263_k2_modus_split.py::test_teilmengen_werden_nirgends_addiert.

Zwei Vorzeichen-Welten. TagesEnergieProfil.komponenten führt die Wärmepumpe negativ (Leistungspfad, seite: "senke"-abs(...)), TagesZusammenfassung.komponenten_kwh positiv (Zählerpfad). waermepumpe_kwh_je_investition liefert für beide Beträge — und ist zugleich die Stelle, die waermepumpe_1 von waermepumpe_12 unterscheidet (die ID ist kein Präfix) und Suffix-Keys (…_heizen) demselben Gerät zuschlägt.

Normierung ist opportunistisch, die Invariante ist der Schutz. Wo der Zählerpfad eine Tagesmenge kennt, wird die Stundenform darauf normiert (Präzedenz v3.45.5). Er kennt sie nicht immer — dann gilt die Roh-Summe. Was Σ Teilmengen ≤ Gesamt hält, ist die Regel im Schreibpfad: größer ⇒ gar nicht schreiben, vorhandene Werte entfernen, Daten-Checker meldet die Monate.

Der Bezug von „nicht aufgeteilt” ist nicht der Gesamtstrom. Anlagenweit trägt strom_kwh auch Wärmepumpen ohne Modus-Sensor; ihr Verbrauch erschiene sonst als unbeobachtete Zeit der Klimaanlage. Bezug ist WpFakten.modus_strom_bezug_kwh — der Strom nur der Geräte mit Split.

Und genau deshalb nennt die Anzeige diesen Bezug seit v4.0.29 (W-17b). Die Kachel „Strom verbraucht” zeigt strom_kwh, der Balken darunter beschreibt modus_strom_bezug_kwh — bei einem Melder 30 gegen 284 kWh. Beide Zahlen waren richtig, nur stand nirgends, dass es zwei verschiedene Grundmengen sind. Die Zeile „Aufgeteilte Menge” erscheint, sobald sie abweichen.

Eine Menge ist additiv, ein Zeitraum nicht (W-17). modus_abdeckung_h wird über Tage summiert und über Geräte maximiert. Zwei Wärmepumpen, die dieselben 18 Stunden liefen, ergeben 18 Stunden Beobachtung, nicht 36 — im Tag sofort sichtbar, im Monat jahrelang plausibel. SoT der Regel: core/berechnungen/modus_split.py::abdeckung_ueber_geraete.

Das Maximum ist eine Untergrenze der exakten Vereinigung, die aus verdichteten Summen nicht mehr rekonstruierbar ist (abdeckung_h ist eine Anzahl, keine Stundenliste). Zwischen einer Zahl, die zu klein sein kann, und einer, die einen Tag mit 36 Stunden behauptet, ist die Wahl keine Geschmacksfrage.

3.5c Abgeleitete Heizwärme und die JAZ-Sperre (#263 K-2, Konzept §3.4/§3.5)

Abschnitt betitelt „3.5c Abgeleitete Heizwärme und die JAZ-Sperre (#263 K-2, Konzept §3.4/§3.5)“

Ohne Wärmemengenzähler wird die Heizwärme aus dem modus-aufgeteilten Strom gerechnet:

heizenergie_kwh = modus_strom_heizen_kwh × Heiz-Effizienz # NUR wenn gepflegt

Die Heiz-Effizienz kommt je nach effizienz_modus aus jaz / scop_heizung / cop_heizung (heiz_effizienz_gepflegt). ⚠ Nie aus einem DefaultPARAM_WAERMEPUMPE_DEFAULTS trägt jaz: 3.5, und wer den anwendet, erfindet für jede ungepflegte Wärmepumpe eine Wärmemenge und damit eine Ersparnis, eine CO₂-Zahl und einen Kostenvergleich.

Geschrieben wird mit Quelle auto:monatsabschluss (AUTO_AGGREGATION, Priorität 3) und der Provenance-Marke jaz_modus_split. „Gemessen schlägt abgeleitet” folgt daraus per Konstruktion: MANUAL ist 1, EXTERNAL_AUTHORITATIVE 2 — beide gewinnen.

Die eine Regel, die daraus folgt — sie trennt teilen von multiplizieren:

darf abgeleitete Wärme verwenden?warum
teilt Wärme durch Strom → JAZ/COPneinheraus käme exakt die gepflegte JAZ — eine Zahl, die nichts misst
multipliziert Wärme mit Preis / η / CO₂-Faktorja, mit Kennzeichnungdie Wärmemenge ist die beste verfügbare Schätzung

Getragen wird die Sperre von WpFakten.jaz_belastbar (Fakten-Leser) bzw. heizwaerme_ist_abgeleitet(source_provenance) (Direktleser). Sie hängt am Wert, nicht an der Bauart: eine Luft-Wasser-WP ohne Wärmemengenzähler fällt unter dieselbe Regel, eine Klimaanlage mit Zähler ist gemessen wie jede andere. Wächter: test_263_k2_modus_split.py::test_keine_jaz_stelle_rechnet_mit_abgeleiteter_waerme (fünf Dateien, sieben Formeln).

Kühlen ersetzt keine Heizung (Konzept E-B). berechne_wp_ersparnis und co2_wp_ersparnis_kg nehmen strom_kuehlen_kwh aus dem Vergleich heraus; die Kosten bleiben in wp_kosten_euro und werden als kuehl_kosten_euro eigens ausgewiesen. Ohne diese Trennung gemessen: −45,04 € Ersparnis und −52 kg CO₂ für eine Klimaanlage, die im Winter geheizt und im Sommer gekühlt hat (nachher +2,48 € / +8,2 kg). Default 0.0 ⇒ ohne Split unverändert.

Dieselbe Abgrenzung gilt für die Arbeitszahl — seit W-14 (26.08.2026). Bis dahin nannte dieser Kasten nur Ersparnis und CO₂, und das war keine Unvollständigkeit im Text, sondern im Code: Die Arbeitszahl war die einzige der drei Größen, an der niemand nachgezogen hatte. Ein Gerät, das im Sommer kühlt, sah dadurch aus wie eine schlechte Heizung.

Und sie gilt für alle Funktionen ohne bewertete Nutzenergie — seit E4 (26.08.2026). arbeitszahl(..., strom_funktionsfremd_kwh=…) bekommt heute Kühlen + Lüften + Entfeuchten aus einer einzigen Größe: WpFakten.modus_strom_funktionsfremd_kwh bzw. ModusStromZeile.funktionsfremd_kwh. Eine Größe statt drei Summanden an vier Aufrufern — die Aufzählung war die Bauform, an der W-14 entstanden ist. Abgezogen, nicht gesperrt: die Mengen bleiben in jeder Bilanz, es ändert sich allein der Nenner.

Offen und bewusst nicht mitgebaut: Der Community-Server kennt nur den Kühlstrom (Feld wp_strom_kuehlen_kwh). Wer Lüften oder Entfeuchten getrennt misst und am Vergleich teilnimmt, sieht dort eine etwas niedrigere Arbeitszahl als im eigenen Cockpit. Es braucht ein neues Feld samt Migration im zweiten Repo; der Vermerk steht an der Stelle in services/community_service.py.

3.5d Arbeitszahl je Funktion und die Arbeitszahl Kühlen (W-4 · W-5)

Abschnitt betitelt „3.5d Arbeitszahl je Funktion und die Arbeitszahl Kühlen (W-4 · W-5)“

Dieser Abschnitt fehlte bis zum 27.08.2026 vollständig — beide Größen waren gebaut und in keiner Referenz beschrieben. Aufgefallen beim Prüfauftrag „§3.5c gegen W-4/W-5 halten”: Der Kasten darüber war richtig, aber er beschreibt eine dritte Regel. Ein Dokument, das niemand gegen den Code hält, produziert beides — vergessene Arbeit und erfundene Arbeit.

Alle vier Arbeitszahlen kommen aus derselben Funktion (core/berechnungen/waermepumpe_kennzahl.py), und das ist der Punkt: Was für eine gilt, gilt für alle.

Arbeitszahl gesamt = waerme_kwh ÷ (strom_kwh − funktionsfremd_kwh)
Arbeitszahl Heizen = heizenergie_kwh ÷ strom_heizen_kwh
Arbeitszahl Warmwasser = warmwasser_kwh ÷ strom_warmwasser_kwh
Arbeitszahl Kühlen = nutzenergie_kuehlen_kwh ÷ betriebsart_strom_kuehlen_kwh
VoraussetzungGrund, wenn sie fehlt
je Funktion (W-4)getrennte_strommessung und die zugehörige WärmemengeGRUND_STROM_NICHT_JE_FUNKTION
Kühlen (W-5)Kühlstrom und KältemengenzählerGRUND_KEINE_KAELTEMENGE · "kein Kühlbetrieb in diesem Zeitraum"

Die R2-Sperren gelten für alle vier — Anwender-Angabe abgrenzung, abgeleitete Wärme, gemischte Bauarten, Geräte ohne Wärme, Zeitraum-Versatz.

R2/Bauart — neu am 28.08.2026 (GRUND_BAUARTEN_GEMISCHT, SOLL §5): Trägt ein Block eine Luft-Wasser-Wärmepumpe und eine Luft-Luft-Split-Klimaanlage, gibt es keine gemeinsame Kennzahl — verschiedene Nutzenergie, verschiedene Vergleichsmaßstäbe. Die Mengen bleiben summiert (§5: „Mengen dürfen nebeneinander stehen, eine gemeinsame JAZ nicht”), und im Komponenten-Hub behält jedes Gerät seine eigene Zahl. ⭐ Erkannt aus den Stammdaten (ist_luft_luft_waermepumpe), nicht aus einer Messreihe — damit ist es nach waerme_deckt_nicht_alle_geraete die zweite Lage, die eedc selbst erkennt. Sie steht vor ihr in der Reihenfolge: Der allgemeinere Grund riete zu einem Wärmemengenzähler, den eine Split-Klimaanlage bauartbedingt nicht haben kann. Genau daran ist W-4 entstanden: Die Funktions-Kennzahlen wurden an einer eigenen Stelle gerechnet und kannten die Sperren nicht. Ein Heizstab auf dem WP-Zähler ließ die Gesamtzahl mit Begründung verschwinden, während „JAZ Heizen” unbeeindruckt danebenstand — dieselbe Anlage, zwei Aussagen. Dazu stand dort eine 0, wo „unbekannt” gemeint war (ADR-002/P4).

Warum die Arbeitszahl Kühlen nicht „SEER” heißt (Entscheid, 26.08.2026): SEER ist eine genormte Größe aus definierten Prüfstandsbedingungen. Was eedc bildet, ist der Quotient zweier Zähler über einen Zeitraum. Sie „SEER” zu nennen behauptete eine Vergleichbarkeit mit Datenblatt-Werten, die sie nicht hat. Anwender-Fassung: Wärme & Klima §3.

Warum keine geschätzte Kältemenge: Aus einem angenommenen Wirkungsgrad käme genau der Faktor zurück, mit dem gerechnet wurde — dieselbe Zirkularität, die §3.5c für die abgeleitete Heizwärme beschreibt, nur auf der Kälte-Achse.

3.5e Anzeige-Regeln der Betriebsart-Aufteilung (W-17 · W-17b · W-18)

Abschnitt betitelt „3.5e Anzeige-Regeln der Betriebsart-Aufteilung (W-17 · W-17b · W-18)“

Drei Regeln, die keine Formel sind und trotzdem in jede Sicht gehören:

RegelKurz
Zeit ist nicht additiv (W-17)modus_abdeckung_h wird über Tage summiert, über Geräte maximiert (abdeckung_ueber_geraete). Zwei Geräte mit je 18 h ergeben 18, nicht 36.
Eine Aufteilung nennt ihre Grundmenge (W-17b)Der Balken bezieht sich auf modus_strom_bezug_kwh, die Kachel darüber auf strom_kwh. Weichen sie ab, steht die Zeile „Aufgeteilte Menge” darunter.
Ein fehlender Wert nennt seinen Grund (W-18)Drei unterscheidbare Zustände (core/tageswert_grund.py): kein Zähler · zugeordnet, aber für diesen Tag ohne Zählerstände · Zählerrücksprung. Der Grund wird hergeleitet, nie behauptetarbeitszahl(waerme_fehlt_grund=…) nimmt ihn entgegen, weil der Layer ihn nicht kennen kann.

Funktion: berechne_roi() in core/calculations.py Nenner-SoT: core/berechnungen/kapitalrechnung.py

Warum eine Zahl dort steht, wo sie steht: docs/KONZEPT-WIRTSCHAFTLICHKEITSRECHNUNG.md. Dort stehen die Entscheidungen samt den verworfenen Alternativen und den Messungen, die sie verworfen haben — inklusive der häufigen Einwände mit Antwort. Dieses Kapitel hier nennt die Formeln, jenes die Begründung.

Relevante_Kosten = Anschaffungskosten - Alternativkosten
Netto_Einsparung = Jahres-Einsparung - Betriebskosten_Jahr
ROI (%) = Netto_Einsparung / Relevante_Kosten * 100
Amortisation (Jahre) = Relevante_Kosten / Netto_Einsparung

Wobei Betriebskosten_Jahr = Investition.betriebskosten_jahr (Wartung, Versicherung etc., Default: 0).

WICHTIG - Zwei verschiedene ROI-Metriken:

MetrikWo angezeigtFormelBedeutung
Jahres-RenditeCockpitKumul. Ersparnis / Relevante Kosten * 100Wie viel % bereits amortisiert (kumuliert)
ROI p.a.Auswertungen → ROI (pro Komponente)Jahres-Einsparung / Relevante Kosten * 100Rendite pro Jahr
Amortisations-FortschrittAuswertungen → ROI (Kachel), Jahresbericht-PDFBisherige Erträge / Relevante Kosten * 100Kumulierter Fortschritt

Ein Nenner für alle drei (N-137, seit 2026-08-04). „Relevante Kosten” sind die Mehrkosten Σ max(0, anschaffungskosten_gesamt − anschaffungskosten_alternativ) — SoT core/berechnungen/investitionskosten.py::relevante_kosten_aus_investitionen, identisch mit der USt-Bemessungsgrundlage (§3.7). Vorher gab es drei Antworten: die ROI-Sicht rechnete ohne Klemmung je Position, Aussichten und Cockpit trugen eine Hybrid-Summe, deren WP-/E-Auto-Mehrkosten aus parameter["alternativ_kosten_euro"] kamen — einem Schlüssel ohne Schreiber, der immer auf die Festannahmen 8.000 € / 35.000 € zurückfiel. Gepflegt wird die Spalte anschaffungskosten_alternativ, die der Daten-Checker mit WARNING einfordert.

Ohne gepflegte Alternativkosten zählen die Vollkosten. eedc setzt keine Annahme mehr ein; die Amortisation fällt dadurch für WP und E-Auto ungünstiger aus als vorher, dafür entspricht sie den Daten, die tatsächlich da sind. Gesichert durch test_amortisation_nenner_symmetrie.py (Regression, drei Sichten auf einer Fixture mit Alternativkosten ≠ Festannahme).

Sonstige Positionen: Zeitraum-Bilanz ↔ Kapitalrechnung (seit 2026-08-09). Eine Reparatur ist zweierlei, je nach Frage — und seit 2026-08-10 gilt dasselbe für eine Förderung:

FrageSichtWo die Position steht
„Was hat der Monat gekostet und eingebracht?”Cockpit, Monatsbericht, Jahresbericht-PDF, CSV-Export, Sensor netto_ertrag_euroErtragsseite — Aufwand bzw. Ertrag des Zeitraums, unverändert
„Wie lange dauert es, bis sich das rechnet?”ROI, Amortisation, HA-Sensoren, Finanzbericht-PDFKapitaleinsatz (Nenner): Ausgabe +, Ertrag

Warum: vorher wurden sonstige Ausgaben im Zähler über die Laufzeit annualisiert. Eine einmalige Reparatur von 3.000 € an einer Wärmepumpe (Einsparung 1.235 €/Jahr) verlängerte die Amortisation dadurch von 8,1 auf 42,6 Jahre, in der Jahressicht erschien das Gerät als nie amortisiert — und die Zahl driftete jedes Folgejahr weiter, ohne dass etwas passierte. Im Nenner ergibt dieselbe Reparatur 10,5 Jahre. Bei laufenden Kosten (Wartung) liefert die kumulative Rechnung sogar exakt dasselbe Ergebnis wie vorher (K + 180n = 1235n ⇒ n = 9,5).

Sonstige Erträge stehen seit 2026-08-10 im Nenner (Bauschritt 7 — die Vollkostenrechnung ist damit vollständig). Eine Position im Monatsabschluss ist per Form einmal geflossen; sie zu mitteln und in jedes künftige Jahr zu verlängern unterstellt eine Wiederholung, die niemand behauptet hat (spiegelbildlich zur Ausgabenseite, seit demselben Tag). Eine THG-Quote oder eine Förderung mindert deshalb das eingesetzte Kapital, statt eine künftige Jahres-Ersparnis zu erhöhen. In der Zeitraum-Bilanz bleibt sie unverändert stehen — dort ist sie ein Ertrag des Zeitraums.

Warum nicht schon mit F-19: ein wiederkehrender Ertrag E im Nenner ergibt (K − E·n) ÷ Z, eine Zahl, die jedes Jahr schrumpft und irgendwann negativ wird. Voraussetzung war deshalb ein Ort für wiederkehrende Erträge, und den gibt es seit demselben Tag gleich zweimal: „Ertrag/Jahr (€)” an der Investition (Wallbox/Sonstiges) und „Einspeise-Erlös (€)” bei Sonstiges/Erzeuger, per HA-Sensor monatsgenau befüllbar. Praktischer Fall: wer zwei Wechselrichter mit verschiedenen Einspeisetarifen betreibt (eedc kennt genau einen Einspeisesatz je Anlage), pflegt den zweiten Erlös dort — beide Felder sind wiederkehrend gemeint und wirken im Zähler. Die monatliche Handpflege bleibt möglich, beschreibt aber die Vergangenheit und mindert seither den Kapitaleinsatz; der Daten-Checker weist auf den besseren Ort hin, wenn derselbe Posten mehrfach auftaucht.

Der Nenner kann dadurch unter die Anschaffungskosten fallen — das ist die Aussage: eine Förderung ist Geld, das nie eingesetzt wurde. Fällt er auf ≤ 0, zeigt eedc keine Amortisationsdauer und keinen ROI statt einer negativen Zahl.

Die Unterscheidung kostet keine zusätzliche Pflege: typ: ertrag|ausgabe wird beim Erfassen ohnehin gewählt, und der Ort sagt die Häufigkeit.

Und der Kapitaleinsatz ist nicht die USt-Bemessungsgrundlage. Die bleibt bei den reinen Mehrkosten (§ 3 Abs. 1b UStG, §3.7) — weder eine Reparatur noch eine Förderung gehört dort hinein. Gesichert durch test_kapitaleinsatz_vier_sichten_symmetrie.py (Regression, vier Sichten + beide Seiten des Bruchs, beide Erfassungsorte — an der Investition und auf der Monatsdaten-Zeile). ⚠ Die zweite Hälfte kam erst am 2026-08-10 dazu: bis dahin trugen anlagenweit erfasste Ausgaben nur die HA-Sensoren, und die Fixture konnte das nicht sehen, weil sie die Position komponentengebunden anlegt.

Jede Ersparnis wird mit IHRER eigenen Laufzeit hochgerechnet. Die HA-Sensoren jahres_ersparnis_euro, roi_prozent und amortisation_jahre teilten bis 2026-08-09 alles durch die Monatszahl der Anlage — auch die Wärmepumpen- und E-Auto-Ersparnis. Eine 2025 nachgerüstete Komponente wurde dadurch anteilig verdünnt, während ihre Kosten voll im Nenner standen. An einer vermessenen Anlage (31 Anlagenmonate, Wärmepumpe 25, zweiter Wagen 12) waren dadurch mindestens 3,1 Jahre reine Verdünnung: 26,4 statt höchstens 23,3 Jahre — eine Schranke, keine Schätzung, denn kein Posten lief länger als 25 Monate. Jetzt bringt jeder Posten seine eigene Monatszahl mit, und der Sensor-Rechenweg schreibt sie aus.

Und zwei verschiedene Amortisations-Angaben — Modell neben Messung:

AngabeWoGrundlage
Amortisationsdauer — Jahre und Break-Even-JahrAuswertungen → ROIMODELL: Relevante Kosten ÷ prognostizierte Jahres-Einsparung, konstant hochgerechnet. Anker des Kalenderjahres ist das früheste Anschaffungsjahr der Investitionen; ohne gepflegtes Anschaffungsdatum bleibt es beim Jahres-Index ohne Jahreszahl.
Amortisations-FortschrittAuswertungen → ROI (Kachel daneben), Jahresbericht-PDFMESSUNG: die tatsächlich erzielten Netto-Erträge seit Inbetriebnahme, geteilt durch dieselben relevanten Kosten. Formel-SoT core/berechnungen/amortisation.py.
Amortisation (Prognose)PDF-FinanzberichtDieselbe Zahl wie Auswertungen → ROI (seit 2026-08-09). Vorher rechnete das PDF Gesamt-Anschaffung ÷ Σ einsparung_prognose_jahr — beide Seiten trugen nicht: der Nenner war die Gesamt- statt der relevanten Kosten, und der Zähler ein Feld ohne Schreiber (in keinem Formular, keinem Import, keinem Update-Schema). In der Praxis stand dort deshalb „—”. Seit 2026-08-10 ist das Feld als „Ertrag/Jahr” pflegbar (Wallbox/Sonstiges) — die Amortisation kommt trotzdem weiter aus dem ROI-Dashboard, damit alle vier Sichten dieselbe Zahl nennen.
Amortisation (HA-Sensor)sensor.*_amortisation_jahreKapitaleinsatz ÷ Jahres-Ersparnis, wobei die Jahres-Ersparnis aus der gemessenen Historie annualisiert wird — je Posten mit seiner eigenen Laufzeit. Trägt denselben Nenner wie die drei anderen, aber einen Ist- statt Modell-Zähler; die Zahl liegt deshalb je nach Anlage über oder unter der Modell-Sicht.

Die beiden ersten beantworten dieselbe Frage verschieden — „laut Rechnung in 9,2 Jahren” gegen „4.800 € von 12.000 € sind drin”. Das ist gewollt und in beiden Tooltips ausgeschrieben; Bedingung ist der gemeinsame Nenner, sonst ließen sich die Zahlen nicht ineinander überführen.

Verteilen sich die Anschaffungen über mehrere Jahre, ist das ausgewiesene Amortisationsjahr optimistisch (der Anker ist die erste Anschaffung, die Kosten sind die Summe). Der Break-Even-Text sagt das dazu.

Jede Dauer nennt ihre Annahme (seit 2026-08-10). Der Fortschritt unterstellt nichts, die Dauer muss etwas unterstellen — gewählt ist Modell A („es geht nie wieder etwas kaputt”, Begründung und die verworfenen Modelle B/C in KONZEPT-WIRTSCHAFTLICHKEITSRECHNUNG.md §5). Formuliert wird der Satz einmal, im Layer-SoT core/berechnungen/kapitalrechnung.py::annahme_dauer_text, und von allen Ausgabewegen abgeholt: ROI-Dashboard (gesamt und je Zeile), PDF-Finanzbericht, HA-Sensor amortisation_jahre (dort im Rechenweg-Attribut — ein Sensor hat keinen Tooltip) und die Restlaufzeit der Finanz-Prognose (amortisation_prognose_jahr): sie steht zwar in der gemessenen Kachel, rechnet aber den offenen Rest mit der Jahres-Prognose hoch und ist damit selbst eine Dauer-Aussage. Das Client-Pendant lib/amortisationAnnahme.ts trägt nur den einen Fall, für den es keine Backend-Zahl gibt: die Wallbox-Dauer im Komponenten-Hub, die aus Anschaffung ÷ Ersparnis im Client entsteht.

Der Satz richtet sich nach den Daten, nicht nach dem Modellnamen. Sobald betriebskosten_jahr gepflegt ist, rechnet eedc Modell C — der Betrag steht als Abzug im Zähler, und „ohne künftige Instandhaltung” wäre dann eine falsche Aussage über die eigene Rechnung. Gesichert durch test_konzept_wirtschaftlichkeit_konformitaet.py::test_schritt6_* (Regression — die vier Quellen werden namentlich aufgerufen).

SoT: core/berechnungen/ust_eigenverbrauch.py (Berechnungs-Layer, ADR-001) Bedingung: Nur wenn Anlage.steuerliche_behandlung == "regelbesteuerung"

Die Selbstkosten je kWh sind eine Jahresgröße. Gerechnet wird deshalb je Kalenderjahr und summiert — auch wenn die Sicht einen mehrjährigen Zeitraum zeigt:

Bemessungsgrundlage = Σ max(0, anschaffungskosten_gesamt − anschaffungskosten_alternativ)
Abschreibung_Jahr = Bemessungsgrundlage / 20 (20 Jahre lineare AfA)
je Kalenderjahr j:
Jahreskosten_j = (Abschreibung_Jahr + Betriebskosten_Jahr) × Monate_j / 12
Selbstkosten_kWh_j = Jahreskosten_j / PV_Erzeugung_j
USt_j = Eigenverbrauch_j × Selbstkosten_kWh_j × USt_Satz / 100
USt_Eigenverbrauch = Σ USt_j
FeldQuelle
BemessungsgrundlageMehrkosten je Investition, geklemmt bei 0 — nicht die Vollkosten. Der volle Kaufpreis eines E-Autos gehört nicht in die Selbstkosten des PV-Stroms; maßgeblich ist, was er gegenüber der Alternative gekostet hat
Betriebskosten_JahrΣ(Investition.betriebskosten_jahr)
Monate_jMonate des Jahres j, die im ausgewerteten Zeitraum liegen (1–12). Ein angeschnittenes Jahr trägt anteilig AfA — sonst stünden zwölf Monate Abschreibung gegen sieben Monate Ertrag
PV_Erzeugung_jPV-Erzeugung des Jahres j aus den Monats-Fakten
USt_SatzAnlage.ust_satz_prozent (DE: 19, AT: 20, CH: 8.1)

Auswirkung: USt wird vom Netto_Ertrag abgezogen — Cockpit, Jahresbericht-PDF, HA-Export, Aussichten und (seit 2026-08-04, Fund N-22) auch Auswertungen → Finanzen + Tabelle. Bis dahin behauptete dieser Satz den Abzug für die Auswertungen bereits, während der Client dort ohne ihn rechnete.

Je Monat verteilt (Auswertungen): die Formel ist linear im Eigenverbrauch, deshalb trägt jeder Monat EV_m × Selbstkosten_je_kWh × USt_Satz. Der Nenner der Selbstkosten ist die Jahres-PV, damit Σ der Monatsbeträge exakt der Jahresbetrag bleibt. Auf Tagesebene gibt es die Größe nicht — Investitionssumme und Jahresertrag lassen sich keinem Tag zuordnen; bei Regelbesteuerung gilt daher Σ Tage ≠ Monat beim Netto-Ertrag (dieselbe bewusste Asymmetrie wie bei CO₂).

Historie (2026-08-04, Funde N-129/N-130): Bis dahin standen im Baum vier Bemessungsgrundlagen nebeneinander — vier Sichten die Vollkosten, das Cockpit eine zusammengesetzte Summe, die als einzige anschaffungskosten_alternativ nicht las. Und die Sichten übergaben die Erzeugung ihres gesamten Zeitraums als „Jahres-Erzeugung”: bei Filter „alle Jahre” stand eine mehrjährige Menge im Nenner gegen eine Ein-Jahres-Abschreibung, die USt fiel um den Faktor der Jahresanzahl zu niedrig aus. Beides ist mit dem Layer-SoT aufgelöst.

Was weiterhin auseinandergeht: welche Menge als Eigenverbrauch eingeht. Cockpit und HA-Export setzen den Netzpunkt-Eigenverbrauch ein (inklusive eines Brennstoff-Erzeugers), Jahresbericht, Aussichten und die Monatszeile den Finanz-Eigenverbrauch (PV allein). Bei einer Anlage mit Mini-BHKW nennen die beiden Gruppen deshalb verschiedene USt-Beträge. Register N-131.

Endpoint: GET /api/cockpit/nachhaltigkeit/{anlage_id} Sichten: Cockpit → Jahr/Gesamt, Block „CO₂-Bilanz” (seit 2026-07-31) · Auswertungen → CO₂, Blöcke „CO₂-Bilanz & Wirkung” und „CO₂-Amortisation” (seit 2026-07-31).

Eine Definition — wer sie bildet und wer sie liest

Abschnitt betitelt „Eine Definition — wer sie bildet und wer sie liest“
RolleOrt
Bildet die Zahl (einzige erlaubte Stelle)core/calculations.py::berechne_co2_bilanz (ADR-001, DI-2)
Liefert sie je Monat ausGET /api/cockpit/nachhaltigkeit/{id} (co2_pv_kg · co2_wp_kg · co2_emob_kg · co2_gesamt_kg · co2_kumuliert_kg)
Zeigt sieCockpit → Jahr (v4/JahrCo2Chart.tsx) · Auswertungen → CO₂ (v4/AuswertungenCo2V4.tsx, über useAuswertungBasis().co2) · HA-Sensor „CO₂ Einsparung” · PDF-Jahresbericht · WP-Dashboard
Rechnet nichtder Client. CO2_FAKTOR_KG_KWH darf dort nur noch angezeigt werden (× 1000 → g/kWh); gewächtert von npm run check:co2-roh (Baseline 0)

Warum das ausgeschrieben dasteht (N-21, 2026-07-31). Bis dahin standen im Produkt drei CO₂-Zahlen für denselben Monat: die kanonische im Cockpit — und zwei Überlebende der DI-2-Ablösung, die Erzeugung × 0,38 rechneten, also auch der eingespeisten kWh die volle Netzstrom-Vermeidung gutschrieben und weder Wärmepumpe noch E-Mobilität kannten (pages/auswertung/types.ts im Client, services/energie_profil/tage_werte.py im Backend — ein Spiegelpaar, Monatstabelle und Tagestabelle). Beide sind auf den Kanon umgestellt. Das war keine Definitionsfrage, sondern eine unvollendete Migration.

Jahres-Scope: Der Endpoint kennt kein ?jahr= und liefert die gesamte Historie — der Jahresfilter sitzt in der Sicht (v4/JahrCo2Chart.tsx::baueJahrCo2ChartDaten bzw. v4/AuswertungenCo2V4.tsx::baueCo2Monatsreihe) und greift auf die ganze Monatszeile, nicht auf einzelne Serien. Nicht jahresgebunden ist co2_kumuliert_kg: eine Lebensdauer-Größe, die deshalb als eigener Kennwert („CO₂ kumuliert”) steht und nicht als Linie im Jahres-Chart — eine kumulierte Kurve, die im Januar auf halber Höhe beginnt, erklärt sich nicht selbst. Aus demselben Grund rechnet die CO₂-Amortisation (Auswertungen → CO₂, Block ②) immer gegen co2_kumuliert_kg der gesamten Historie, auch wenn ein Einzeljahr gefiltert ist — sichtbar gekennzeichnet.

Die Spalte „CO₂-Einsparung (PV)” der Werte-Tabelle (Auswertungen → Tabelle, Monats- und Tages-Granularität) zeigt bewusst nur co2_pv_kg:

CO2-Einsparung (PV) = Eigenverbrauch * 0.38

Warum nicht die volle Bilanz: WP-Wärme und E-Mobilitäts-Kilometer sind Monatsgrößen (InvestitionMonatsdaten). Stündlich liegt von der Wärmepumpe nur die Stromaufnahme vor (TagesEnergieProfil.waermepumpe_kw) — ohne Wärmemenge ist die WP-Ersparnis nicht bestimmbar, und eine allein aus dem Stromverbrauch gebildete Komponente wäre rein negativ. Eine Spalte, die im Monat drei Quellen und am Tag eine addiert, wäre über die Granularitäten nicht summierbar.

Folge, die nicht stillschweigend bleiben darf: Σ Tage ≠ CO₂-Monatswert, sobald die Anlage eine Wärmepumpe oder ein E-Auto hat. Die Differenz ist genau max(0, CO2_WP) + max(0, CO2_E-Mob). Die vollständige Bilanz zeigen Cockpit → Jahr und Auswertungen → CO₂.

CO2_PV = Eigenverbrauch * 0.38 (vermiedener Netzstrom)
CO2_WP = (WP_Wärme / 0.9 * 0.201) - (WP_Strom * 0.38) (vs. Gasheizung)
CO2_E-Mob = (Benzin_L * 2.37) - ((Ladung - PV_Ladung) * 0.38) (vs. Benziner)
CO2_gesamt = CO2_PV + max(0, CO2_WP) + max(0, CO2_E-Mob)

Kanonische CO₂-Helfer (SoT core/calculations.py, DI-1/DI-2): Diese Bilanz läuft über genau eine Helfer-Familie — berechne_co2_bilanz (setzt PV + WP + E-Mob zusammen), intern co2_wp_ersparnis_kg (WP: vermiedenes Gas MINUS WP-Strom-CO₂; der Gas-Wirkungsgrad η_gas = 0,90 kommt aus WP_WIRKUNGSGRAD_GAS_DEFAULT) und co2_emob_ersparnis_kg. Cockpit-CO₂-Kachel, HA-Export-Sensor „CO₂ Einsparung”, WP-Dashboard und der PDF-Jahresbericht rufen dieselben Helfer auf und zeigen daher denselben Wert (vorher rechneten einzelne Pfade pv_erzeugung × f_strom bzw. eigene WP-Formeln → Drift). Die WP- und E-Mob-Komponente werden für die Summe bei 0 geklammert (negative Einzelwerte kürzen die Gesamtbilanz nicht). Brennstoff-Erzeuger (BHKW/„sonstiges”) erzeugen bewusst keine CO₂-Gutschrift — sie zählen zwar in EV/Autarkie (hinter dem Zähler), aber nicht als vermiedenes CO₂.

Bäume = CO2_gesamt / 20 (kg/Baum/Jahr)
Auto-km = CO2_gesamt / 0.12 (kg/km)
Flug-km = CO2_gesamt / 0.25 (kg/km)

Endpoint: GET /api/cockpit/pv-strings/{anlage_id}?jahr=

Was eine „String”-Zeile ist — die Erzeuger-Abgrenzung (F-10)

Abschnitt betitelt „Was eine „String”-Zeile ist — die Erzeuger-Abgrenzung (F-10)“

Die Zeilen dieser Sicht sind PV-Erzeuger, nicht PV-Module. Maßgeblich ist PV_ERZEUGER_TYPEN (SoT core/berechnungen/spez_ertrag.py) = pv-module + balkonkraftwerk. Ein Balkonkraftwerk trägt alles, was die Sicht braucht: kWp über get_erzeuger_kwp (beim BKW leistung_wp × anzahl), Ausrichtung und Neigung als eigene Formularfelder, und seit #367 ein eigenes PVGIS-SOLL.

Betroffen sind vier Ausgaben derselben SichtGET /pv-strings, GET /pv-strings-gesamtlaufzeit, Abschnitt 10 des Jahresbericht-PDF und die beiden Leertexte im Client. #367 hatte nur die zwei PVGIS-Endpunkte erweitert und im Issue-Text „zwei Endpunkte” behauptet; es waren fünf. Klasse #236 — ein Filter auf einer Schicht reicht nicht, wenn parallele Pfade existieren.

Die IST-Quelle ist je Typ eine andere, und das ist Absicht. Ein pv-module holt seinen Wert aus ErzeugungFakten.pv_je_modul (P7-Auflösung); ein balkonkraftwerk steht dort nicht, sondern in BkwFakten.erzeugung_je_investition. Grund: die Σ von pv_je_modul ist pv_module_kwh, und die geht in die ROI-Rechnung, wo das Balkonkraftwerk eine eigene Zeile hat (investitionen/crud.py::get_pv_erzeugung) — läge es in beiden, zählte seine Erzeugung dort doppelt. Wer die Sicht erweitert, erweitert deshalb nicht pv_je_modul. Gewächtert in tests/test_bkw_erzeuger_sichten_f10.py.

Kein zweiter Erfassungsweg. pv-module unter balkonkraftwerk bleibt verboten (models/investition.py::ERLAUBTE_PARENT_TYPEN): das wäre dieselbe Erzeugung zweimal erfasst, mit doppelter kWp als Folge — der Workaround, den der Melder selbst zurückgenommen hat. Wer mehrere Ausrichtungen hat, erfasst Wechselrichter + PV-Module; die Abgrenzung steht in HANDBUCH_EINSTELLUNGEN §3.5.

Dieselbe Erzeuger-Abgrenzung gilt für die Community-Stammdaten (services/community_service.py): Neigung und Ausrichtung werden über beide Typen gemittelt. Vorher fiel eine reine Balkonkraftwerk-Anlage auf die Annahme 30° / Süd zurück — der Community-Server rechnet nichts nach, die Anlage wurde also gegen die falsche Vergleichsgruppe gemessen.

Genau eine Prognose ist die aktive. eedc bewahrt beliebig viele PVGIS-Abrufe einer Anlage auf; gelesen wird ausschließlich die als aktiv markierte — auch wenn das bewusst eine ältere ist (Nutzerwille, Einstellungen → Solarprognose). Auswahlregel: ist_aktiv == True, ORDER BY abgerufen_am DESC, LIMIT 1; SoT services/prognose_auswahl.py, datenbankseitig gesichert durch einen partiellen Unique-Index (ADR-002/P5). Ist keine Prognose aktiv, bleibt die SOLL-Seite leer, statt eine beliebige zu zeigen.

Wann eine Prognose von selbst nachgezogen wird (#363). Eine Prognose wird beim Abruf eingefroren; ändert sich die Anlage danach, rechnet jede SOLL-Sicht gegen eine Anlage, die es nicht mehr gibt (gemeldeter Extremfall: 357 MWh Jahres-SOLL für ein 2,4-kWp-Balkonkraftwerk). Der nächtliche Job pvgis_aktualitaet prüft deshalb je Anlage, ob die aktive Prognose noch passt — SoT services/pvgis_aktualitaet.py, dieselbe Funktion versorgt die Statusanzeige der Einstellungs-Kachel. Auslöser sind ausschließlich:

AuslöserVergleich
NennleistungΣ get_erzeuger_kwp der aktiven Erzeuger gegen gesamt_leistung_kwp
Ausrichtung / Neigungnach kWp gewichtet, wie im Speicherpfad
Standortlatitude/longitude der Anlage
Horizontprofilhinzugekommen oder entfernt
Strahlungsdatensatzraddatabase der Zeile gegen den der konfigurierten API-Version

Das Alter ist ausdrücklich KEIN Auslöser. PVGIS rechnet auf einem abgeschlossenen Klimamittel (API v5_2 → PVGIS-SARAH2 2005–2020, v5_3 → PVGIS-SARAH3 2005–2023); bei unveränderten Eingaben liefert ein zweiter Abruf dieselbe Zahl, ein turnusmäßiger Abruf wäre Last ohne Wirkung. Die Systemverluste sind ebenfalls kein Auslöser, sondern werden in den Neuabruf übernommen — sie sind nirgends sonst gespeichert. Die abgelöste Prognose bleibt als inaktive Zeile erhalten.

Mit #363 hebt eedc die API-Version von v5_2 auf v5_3. Damit wechselt der Strahlungsdatensatz von SARAH2 auf SARAH3 — für dieselbe Anlage rund +2 % (am 2026-08-07 gemessen: 9,8 kWp Süd 35°, 10.495,79 → 10.727,57 kWh). Bestandsprognosen tragen kein raddatabase und werden deshalb genau einmal automatisch nachgezogen; danach rechnen alle Installationen auf derselben Grundlage.

Wann die AC-Kappung NICHT greift (F-11). Die Grenze begrenzt, was ein Wechselrichter ins Haus abgibt — nicht, was die Module ernten. Hängt am Träger der Grenze ein DC-gekoppelter Speicher, läuft der Überschuss gleichstromseitig in den Akku, ohne je durch den Wechselrichter zu müssen; er ist dann nicht verloren, und eine Kappung des Erzeugungsprofils würde ihn wegrechnen. SoT core/berechnungen/wr_kappung.py::_dc_speicher_traeger.

LageKappung
kein Speicher am Trägerja (#347/#354 unverändert)
DC-gekoppelter Speicher am Trägernein — der Überschuss lädt den Akku
AC-gekoppelter Speicher am Trägerja — alles läuft durch den Wechselrichter
Speicher ohne Zuordnung, oder an einem anderen Wechselrichterja — er kann den Überschuss dieses Erzeugers nicht aufnehmen

Warum die IST-Größe die Ernte vor dem Speicher ist — das entscheidet nicht der Hersteller, sondern die eigene Bilanz: direktverbrauch = max(0, pv − einspeisung − speicher_ladung) (§3.1). Die Speicherladung wird von der PV-Summe abgezogen, sie muss darin enthalten sein. Die Sensor-Referenz sagt dasselbe in Worten, und der BKW-Akku-Kanon führt Ladung/Entladung als eigene Speicher-Investition daneben.

Damit ändert die Kopplung eines Speichers erstmals eine Zahl. Bis v4.0.10 war parameter.kopplung (#351) rein beschreibend — sie sagte, wo gemessen wird, und keine ADR-001-Formel las sie. Ab F-11 liest wr_kappung sie, und die Wirkung bleibt auf SOLL-Werte beschränkt: Prognose-Kanon und PVGIS-Monatsprognose. Kein IST-Pfad, keine Energiebilanz, keine Finanzrechnung. Wer die Kopplung eines Speichers ändert, verschiebt seither sein SOLL.

Ab v2.3.2 (Per-Modul PVGIS-Daten vorhanden):

SOLL_Monat = PVGISPrognose.module_monatswerte[modul_id][monat].e_m

Fallback (ältere Prognosen - proportional nach kWp):

kWp_Anteil = Modul_kWp / Gesamt_kWp
SOLL_Monat = PVGISPrognose.monatswerte[monat].e_m * kWp_Anteil

Der PDF-Jahresbericht nutzt seit v4.0.1 denselben Weg (vorher verteilte sein String-Vergleich die Prognose immer nach kWp, obwohl die Pro-Modul-Werte gespeichert sind — bei Ost-West-Dächern ~20–25 % Abweichung gegenüber dem Cockpit).

Faire Vergleichsbasis (ab v2.3.2): SOLL wird NUR für Monate gezählt, die auch IST-Daten haben. Verhindert aufgeblähten SOLL bei Teil-Jahren.

Der laufende Monat zählt anteilig (ab v4.0.9, N-69): PVGIS liefert Monatssummen — im laufenden Monat stünde diese volle Summe als Nenner über einem angefangenen Ertrag. Die Quote maß dann das Datum statt die Anlage: am 4. August meldete eine gesunde Anlage 19 % SOLL-Erfüllung (264,8 IST gegen 1.387,9 SOLL), während dieselbe Anlage über Jan–Jul auf 119 % kam; in der Jahres-Kachel wurden daraus 104 % statt 119 %.

Tage = min(heutiger Tag, Tage im Monat) im laufenden Monat, sonst alle Tage
SOLL_kWh = PVGIS-Monatswert × Tage ÷ Tage im Monat

Gekürzt wird der Nenner, nicht das Zeitfenster des IST (Entscheid 2026-08-04) — sonst verlöre die Monatssicht ihre einzige Einordnung des PV-Werts bis zum Monatsabschluss. Der laufende Tag zählt voll mit: ihn wegzulassen machte den Nenner kleiner und die Quote höher, also genau die Richtung, aus der der Fehler kam. Innerhalb des Monats gilt Gleichverteilung; am Beispiel oben liegt die Jahresquote damit bei 119,8 % gegen 119,2 % aus den abgeschlossenen Monaten allein.

Ein Monat in der Zukunft hat null Tage und damit kein SOLL — die Sichten lassen die Quote weg, statt 0 % für einen Monat zu melden, der noch nicht stattgefunden hat. Abgeschlossene Monate und damit die gesamte Historie bleiben unberührt.

SoT der Formel: core/berechnungen/monatsfenster.py (auch die Grundlast-Hochrechnung und die Speicher-Auslastung zählen ihre Tage dort, statt jede für sich). Die Jahres-Sicht summiert die Monatswerte und erbt die Kürzung ohne eigene Rechnung; die Oberfläche weist das Fenster aus („anteilig · 4 von 31 Tagen”).

Der Anschaffungsmonat zählt ebenso anteilig (ab v4.0.19, F-34): Dieselbe Schieflage gibt es am anderen Ende — nur kommt die Kante dort nicht aus dem Kalender, sondern aus den Stammdaten. Eine Anlage, die am 19.03. ans Netz ging, bekam den vollen PVGIS-März gegenübergestellt: 175,1 kWh SOLL gegen 60,8 gemessene, Performance Ratio 0,347 — während dieselbe Anlage in jedem vollen Monat über 1,0 lag (April 1,039 · Juli 1,139). Nicht die Anlage war schwach, der Vergleich war schief; das Jahres-PR fiel dadurch von ~1,08 auf 0,973.

Tage = Tage des Monats zwischen Anschaffungs- und Stilllegungsdatum (beide inklusive)
SOLL_kWh = PVGIS-Monatswert × Tage ÷ Tage im Monat

Gekürzt wird wieder der Nenner, aus demselben Grund. Der Stilllegungsmonat ist die Spiegelkante und wird genauso behandelt.

Zwei Datums-Ebenen, zwei Formeln — bewusst nicht zusammengelegt. monatsfenster beantwortet „wie viel des Monats ist am Stichtag vergangen” (Kalender), monatsfenster_investition „wie lange gab es dieses Gerät in diesem Monat” (Stammdaten). Wer beide braucht, ruft beide und nimmt das kleinere Fenster. Das ist dieselbe Trennung, die ARCHITEKTUR §4 für anschaffungsdatum gegen Anlage.installationsdatum zieht — ihre Vertauschung hat schon zweimal zu Abstürzen geführt.

Die saisonale Zeile der Laufzeit-Sicht kürzt bewusst nicht: sie stellt einen IST-Durchschnitt über mehrere Jahre einem Klimamittel gegenüber und fragt „wie fällt der Mai typischerweise aus”. Ein einzelner angebrochener Anschaffungsmonat verzerrt dort bereits den IST-Durchschnitt; beide Seiten zu kürzen hieße, ihn doppelt zu bestrafen. Melder: Discussion #366 (azywietz-web).

SOLL im Monatsbericht: derselbe Grundsatz — der Monatswert kommt aus den Monatszeilen genau der aktiven Prognose. Vor v4.0.1 stand dort eine Summe über alle aktiven Prognosen; bei einem Bestand mit zwei aktiven war der SOLL-PV-Wert verdoppelt, und mit ihm die SOLL/IST-Abweichung und die Grundlast-SOLL-Kachel.

Das PVGIS-SOLL rechnet aus der Modulleistung (DC) und kennt die Grenze des Wechselrichters nicht. Bei einer überbelegten Anlage ist es damit systematisch unerreichbar: was das Gerät mittags abriegelt, taucht im SOLL/IST-Vergleich als Minus auf, das der Betreiber nicht zu verantworten hat.

Die Kappung wirkt stündlich, nie als kWp-Deckel — ein 7-kW-Gerät begrenzt die Mittagsspitze, nicht den Morgen (Begründung im Modul-Docstring core/berechnungen/wr_kappung.py). Weil PVcalc nur Monatssummen liefert, entsteht der Faktor aus einem eigenen seriescalc-Stundenprofil derselben Anlage:

Faktor_Monat = Σ min(Stunde, AC-Grenze) ÷ Σ Stunde (über 2018–2020, ertragsgewichtet)
SOLL_Monat = PVcalc.e_m × Faktor_Monat

Damit bleibt PVGIS die einzige Ertragsquelle; hier entsteht kein zweiter Ertragswert, nur ein Faktor ≤ 1. Drei Jahre statt einem, weil der Faktor sonst das Wetter eines Einzeljahres trägt (am Demo-Standort schwankt der April zwischen 0,804 und 0,875).

Die Grenze gehört dem Wechselrichter, nicht der Himmelsrichtung. Mehrere Strings an einem Gerät teilen sich seine AC-Grenze und werden gemeinsam gekappt, anteilig nach ihrem Stundenbeitrag — auch über Orientierungsgruppen hinweg. Am Demo-Bestand (Süd 12 · Ost 5 · West 3 kWp an einem 10-kW-Fronius) ist das der ganze Effekt: je String einzeln gekappt bliebe das SOLL unverändert, weil kein einzelner String allein 10 kW erreicht — gemeinsam liefern sie 1.227 kWh im Jahr, die das Gerät nie abgeben kann (20.812 → 19.585 kWh, −5,9 %; April −10 %, November/Dezember 0).

Woher die Grenze kommt (SoT core/investition_kennwerte.get_wr_grenze_kw, Zuordnung wr_kappung.zuordne_grenzen):

ErzeugerGrenzeGeteilt?
balkonkraftwerkeigener Parameter wechselrichter_leistung_wnein — Erzeuger und Wechselrichter sind ein Gerät
pv-modulemax_leistung_kw des zugeordneten Wechselrichters (Fallback: Legacy leistung_ac_kw, dann die Spalte leistung_kwp, die dort kW AC trägt)ja — alle Strings desselben Geräts

Ohne gepflegte Grenze wird nicht gekappt (None, kein Default) und PVGIS gar nicht zusätzlich gefragt — die Prognose bleibt dann bitgleich zu vorher. Fällt der seriescalc-Abruf aus, gibt es keine Faktoren statt geratener: ein ungekapptes SOLL ist eine bekannte Größe, ein halb gekapptes wäre keine.

Derselbe Layer kappt seit v4.0.4 die Tages-Prognose (services/prognose_kanon.py); seit v4.0.9 sehen beide Pfade dieselben Grenzen und dieselbe Gruppierung.

Der IST-Wert je Modul kommt aus dem Read-time-SoT core/berechnungen/pv_verteilung.py (resolve_pv_je_modul) — nicht aus einem rohen Feldzugriff. Präzedenz:

1. Messwert InvestitionMonatsdaten.verbrauch_daten["pv_erzeugung_kwh"]
→ Quelle „gemessen" — IMMER und AUSNAHMSLOS
2. Lücke füllen (Monatsdaten.pv_erzeugung_kwh − Σ gemessene) × kWp_Anteil,
nur auf die Module OHNE eigenen Wert
→ Quelle „geschätzt (kWp-Anteil)", in der Anzeige gekennzeichnet
3. keine Quelle kein Wert (kein 0)

Die Präzedenz ist modulweise, nicht anlagenweit (ab 2026-07-29). Bis dahin genügte ein Modul ohne eigenen Wert, damit der Gesamtwert über alle Module verteilt wurde — die echten Messwerte der übrigen Strings wurden dabei verworfen und durch kWp-Anteile ersetzt. Dafür ist der Gesamtwert nicht da: er füllt Lücken, er überschreibt keine Messungen. Teil-Messung ist auch kein Sonderfall — sie entsteht bei jedem Sensor-Aussetzer, jedem neu angelegten String und in jedem Monat vor der Umstellung auf Pro-String-Messung.

Übersteigt die Summe der Messwerte den Gesamtwert, wird der Rest auf 0 geklemmt statt negativ verteilt (Σ > Gesamtwert) — das ist ein Messfehler und gehört gemeldet, nicht weggerechnet.

Der Gesamtwert ist ausschließlich Eingang dieser Auflösung. Er darf in keiner einzelnen Berechnung direkt gelesen werden; jede PV-Zahl kommt aus der Pro-Modul-Schicht bzw. deren Summe. Zielbild für die Erfassung: alle Strings erfassen und „PV gesamt” auf „keine” setzen — zusammengefasst wird höchstens je Ausrichtung/Neigung, sonst kippt die Multi-Orientierungs-Prognose. Die anteilige Verteilung ist ein Übergangswerkzeug, kein Dauerzustand.

Der kWp-Anteil ist ein Prognose-, kein Ertragsschlüssel (ADR-002/P2): auf der IST-Seite verteilt er nur, wenn kein Messwert existiert — und dann sichtbar beschriftet. Solange die Werte verteilt sind, nennen die String-Sichten bewusst keinen besten oder schwächsten String: eine Platzierung wäre dort nur die Reihenfolge der Nennleistungen.

Regel 1 gilt nur für echte Messungen (#352, seit v4.0.9)
Abschnitt betitelt „Regel 1 gilt nur für echte Messungen (#352, seit v4.0.9)“

Die Präzedenz oben liest „ein eigener Wert in der Zeile = gemessen”. Das stimmt nur, solange in der Zeile keine gerechnete Zahl steht — und zwei Schreibwege legen genau das dorthin: der Import einer Legacy-Gesamtspalte (_distribute_legacy_pv_to_modules, aus CSV-Backup, Portal- und Custom-Import) und der Monatsabschluss, wenn der zerlegte Vorschlag von Connector oder Cloud-Import übernommen wird (_mapped_or_distribute). Beide schrieben ihre Aufteilung bis dahin mit derselben Herkunft wie eine Gerätemessung; danach klassifizierte Regel 1 sie als gemessen — mit Ranking und grünem Daten-Checker als Folge.

Seither vermerken diese Wege am Wert, dass er gerechnet ist: InvestitionMonatsdaten.source_provenance["verbrauch_daten.<feld>"]["abgeleitet"] trägt kwp_anteil (PV) bzw. kapazitaet_anteil (Speicher). SoT der Marken und ihrer Positivliste: services/provenance.py (ABGELEITET_*, gepruefte_ableitung). Der Ladepfad services/pv_monatswerte.py liest sie aus derselben Zeile — kein Join, keine zweite Abfrage — und übergibt sie als PvModul.eigen_ist_abgeleitet; resolve_pv_je_modul liefert dafür QUELLE_VERTEILT statt QUELLE_GEMESSEN, ohne den Wert anzufassen.

Drei Grenzen, alle bewusst:

  • Ein Empfänger = keine Zerlegung. Geht der Gesamtwert an genau ein Modul bzw. einen Speicher, ist er unverzerrt dort und bleibt gemessen — dieselbe Grenze, die der Monatsabschluss für Beschriftung und Konfidenz schon vorher zog (ist_verteilt).
  • Der Client meldet die Herkunft, das Backend rät sie nicht. Nur die Oberfläche weiß, ob der Anwender den zerlegten Vorschlag übernommen oder eine eigene Zahl getippt hat; sie schickt die Marke im Investitions-Payload als abgeleitet_felder mit (gleiches Muster wie geprueft_gegen). Client-SoT: lib/erfassungZustand.ts::abgeleiteteMarke — sie schweigt, wenn ein gleich hoher Vorschlag ohne Marke danebensteht, weil dann nicht entscheidbar ist, welcher übernommen wurde.
  • Kein Altbestand-Heilen. Bereits gespeicherte Zeilen bleiben mehrdeutig: derselbe writer steht dort für „echte Pro-Modul-Spalte aus der CSV” und für „verteilt”. Rückwirkend trennen geht nicht ([[feedback_kein_grosser_heiler_knopf]]); die Marke wirkt ab dem nächsten Schreibvorgang.

Der Daten-Checker zählt markierte Werte nicht als Messung. Damit ein vollständig importierter Monat dadurch nicht von OK auf ERROR fällt, kennt klassifiziere_pv_monat den Parameter n_abgeleitet: decken die gerechneten Werte den Monat ab, ist er verteilt (INFO) — die Zahlen sind da, sie sind nur nicht gemessen.

Benannte Ausnahme (ADR-002/P2-A): Ist nur ein Teil der Module gemessen und kein Gesamtwert hinterlegt, behält die Pro-Modul-Sicht ihre Messwerte, während die Anlagen-Summe bewusst nichts zeigt. Σ Strings ≠ Σ Anlage ist dort gewollt — eine Teilsumme als „Gesamt-PV” auszuweisen wäre systematisch zu klein.

Beim Import und beim Monatsabschluss gilt dieselbe Rangfolge auf der Vorschlags-Seite: Ist ein Connector-Feld einer Komponente zugeordnet, geht der volle Zählerstand dorthin („Vom Wechselrichter (Zählerstand-Differenz)”); ohne Zuordnung wird nach Nennleistung verteilt und heißt dann „Gesamtwert, anteilig nach kWp auf die Strings verteilt” — mit niedrigerer Konfidenz als jede gemessene Quelle. Der Zuordnungs-Schritt des Import-Wizards schlägt die Anteile ebenfalls nach Nennleistung vor (bzw. nach Kapazität bei Speichern); ist die Bezugsgröße nirgends gepflegt, verteilt er gleichmäßig und sagt dazu, dass das keine proportionale Aufteilung ist.

Die Präzedenz oben gilt für Monatswerte. Auf der Tagesebene gibt es sie nicht — dort wird nicht verteilt:

erzeuger_kwh[inv_id] = Σ komponenten_kwh[pv_<id> | bkw_<id>] (Boundary-Rollup)
∨ Σ TagesEnergieProfil.komponenten je Stunde (Fallback, kein Rollup)
kein eigener Sensor ⇒ kein Eintrag (kein 0, kein kWp-Anteil)

SoT der Formel: core/berechnungen/energie.py::erzeuger_kwh_je_investition, ausgeliefert als TagWerteResponse.erzeuger_kwh (GET /api/energie-profil/{id}/tage-werte). Zwei Eigenschaften sind dabei nicht optional:

  • Der Schlüssel ist die Investitions-ID, nicht der Komponenten-Key. Dasselbe Balkonkraftwerk heißt im Live-Keyspace pv_<id> und im Boundary-Keyspace bkw_<id> (snapshot/komponenten_beitraege._TYP_PREFIX gegen live_komponenten_builder). Je Roh-Key gruppiert bekäme ein Gerät zwei Spalten, deren Belegung vom Schreibpfad des jeweiligen Tages abhängt — dieselbe Mismatch-Klasse wie der BKW-Doppelzählungs-Bug vom 2026-05-19.
  • Keine kWp-Verteilung. Der Monatspfad füllt Lücken nach Nennleistung und kennzeichnet das; eine so gefüllte Tageszahl unter der Überschrift „Dach Süd” wäre von einer Messung nicht mehr zu unterscheiden (die Klasse aus #352). Fehlt der Sensor, nennt die Oberfläche das Gerät und den Weg zur Zuordnung, statt eine Spalte zu zeigen.
Der Anlagen-Zählerstand: Summe ja, Aufschlüsselung nein (ab 2026-08-07)
Abschnitt betitelt „Der Anlagen-Zählerstand: Summe ja, Aufschlüsselung nein (ab 2026-08-07)“

Das Feld Anlage (Basis) → PV-Erzeugung Zählerstand (kWh) landet über basis["pv_gesamt"] in Monatsdaten.pv_erzeugung_kwh und ist damit Eingang der Monats-Auflösung (P7). Seit 2026-08-07 ist es zusätzlich ein Snapshot-Zähler der Kategorie pv (snapshot/keys.py::BASIS_ZAEHLER_FELDER) und trägt damit auch Tag und Stunde — als Summe der ganzen Anlage. Wer nur einen Summenzähler hat, bekommt also vollständige Tageswerte; was fehlt, ist allein die Aufschlüsselung je Erzeuger (obenstehende Formel liefert für ihn kein pv_<id>).

Bis dahin galt hier das Gegenteil („erreicht die Tagesebene gar nicht”), und das war der Fehler F-7: eine Anlage mit einem Zähler und mehreren Ausrichtungen hatte gar keine Tages-PV. Der naheliegende Ausweg — alle Ausrichtungen als eine Investition führen — bleibt falsch: services/pv_orientation.py gruppiert je Investition nach (Neigung, Azimut), eine zusammengelegte Anlage bekäme einen systematisch falschen Tagesgang im gesamten Prognose-Kanon inklusive HA-Prognose-Sensoren und PVGIS-SOLL.

Die Regel ist alles-oder-nichts, nicht anteilig. Der Anlagen-Zählerstand zählt auf der Tagesebene nur mit, solange kein Erzeuger einen eigenen kWh-Zähler trägt — genau wie im Live-Pfad (not has_individual_pv). Sobald einer misst, gilt für Tag und Stunde nur noch, was je Erzeuger gemessen ist. Zwei Gründe:

  • Nebeneinander ginge nicht. komponenten_kwh hat einen flachen Keyspace, und die Tages-PV ist die Summe aller pv_/bkw_-Schlüssel (summe_pv_bkw_kwh). Stünde pv_gesamt neben pv_7, wäre die Anlagensumme neben ihrem eigenen Summanden gebucht — Doppelzählung.
  • Der Rest ließe sich nur raten. Die Differenz „Anlagensumme minus gemessene Erzeuger” auf die übrigen zu verteilen, wäre eine kWp-Schätzung mit dem Aussehen einer Messung — dieselbe Klasse, die der Absatz „Keine kWp-Verteilung” oben ausschließt.

Folge für die Praxis: ein halber Umbau macht die Tageswerte schlechter, nicht besser. Wer einem von drei Strings einen eigenen Zähler zuordnet, verliert die anderen beiden auf der Tagesebene. Die Zuordnungs-Fläche und der Daten-Checker sagen das an der Zeile (datenquellen_validierung.finde_aggregat_teilweise_verdraengt, WARNING nur in dieser Teilbelegung — nicht, wenn der Summenzähler die ganze Anlage trägt). Die Monatswerte sind in allen drei Lagen vollständig.

Bleibt gar kein kumulativer PV-Zähler übrig — weder je Erzeuger noch für die Anlage —, sagt die Tagessicht das, statt zu rechnen: TagesBilanz.pv_erfasst trennt „0 kWh gemessen” (Nacht, Schnee — gültig) von „nicht erfasst”. Im zweiten Fall bleiben Erzeugung, Eigenverbrauch, spezifischer Ertrag, Performance Ratio und CO₂ leer. Vorher stand dort 0 − Einspeisung, also ein negativer Eigenverbrauch neben einem Peak-PV-Wert aus dem Leistungssensor (Forum kaba-kakao, T89667 #109). Regel-SoT: KONZEPT-UNVOLLSTAENDIGE-WERTE — eine Summe darf 0 bleiben, eine Differenz mit fehlendem Summanden nicht. Die Zuordnungs-Seite und der Daten-Checker nennen in dieser Lage den Weg über einen HA-Integral-Sensor je Erzeuger (HANDBUCH_DATEN_CHECKER §5.2).

Der Client schlüsselt ab zwei Erzeugern auf (lib/erzeugerSpalten.ts, geteilt von Cockpit → Tag und Auswertungen → Tabelle) und berücksichtigt Anschaffungs-/Stilllegungsdatum. Im Stundenverlauf ersetzen die Geräte-Flächen die PV-Fläche, statt auf ihr zu liegen; der ungedeckte Rest (pvRestKw) steht als „PV (übrige)” daneben, damit die Stapelhöhe die Erzeugung bleibt. Die Energie-Bilanz (erzeugung, pv_anlage, bkw) rechnet unverändert aus ihren eigenen Quellen — die Aufschlüsselung ist eine Auskunft, keine Summe.

Abweichung_kWh = IST - SOLL
Abweichung_% = (IST - SOLL) / SOLL * 100
Performance_Ratio = IST / SOLL
Spez. Ertrag (kWh/kWp) = IST_Jahr / Modul_kWp

Ohne aktive PVGIS-Prognose entfallen Abweichung und Performance Ratio (None); IST, Ertragsanteil und spezifischer Ertrag bleiben. Die Sicht zeigt sie seit v4.0.9 auch an — bis dahin brach sie ohne Prognose vollständig ab und verbarg damit auch die gemessenen Werte (#350).

Utility: utils/sonstige_positionen.py

Sonstige Positionen sind frei erfassbare Erträge/Ausgaben je Monat (Reparaturen, Wartung, THG-Quote, Abschlag, Guthaben-Auszahlung …), erfasst im Monatsdaten-Formular. Es gibt sie auf zwei Ebenen:

  • Komponenten-Ebene: InvestitionMonatsdaten.verbrauch_daten["sonstige_positionen"] (seit jeher).
  • Anlage-/Basis-Ebene (G19-1, ab v4.0): Monatsdaten.sonstige_positionen — für Positionen, die keiner einzelnen Komponente zuzuordnen sind. Gelesen über den Spiegel-Helper get_md_sonstige_positionen.

Vorrang neues Format: get_sonstige_positionen() liest zuerst sonstige_positionen; nur wenn der Schlüssel fehlt, greift der Legacy-Fallback sonderkosten_euro/sonderkosten_notiz (→ eine Ausgabe-Position). Eine additive Start-Migration materialisiert Alt-sonderkosten_euro > 0 als „… (migriert)“-Ausgabe; die Legacy-Spalten bleiben lesbar (deprecated, nicht neu befüllen). Wirkung für Bestandsdaten: Alt-Sonderkosten, die bis v3.45 in keiner Berechnung auftauchten, zählen ab v4.0 in den Finanz-Summen (s. §3.2).

Jede InvestitionMonatsdaten.verbrauch_daten bzw. Monatsdaten-Zeile kann sonstige Positionen enthalten:

{
"sonstige_positionen": [
{"bezeichnung": "THG-Quote", "betrag": 200.00, "typ": "ertrag"},
{"bezeichnung": "Wartung", "betrag": 50.00, "typ": "ausgabe"}
]
}

Legacy-Format (backward-kompatibel):

{"sonderkosten_euro": 50.0, "sonderkosten_notiz": "Wartung"}

wird automatisch zu [{"bezeichnung": "Wartung", "betrag": 50.0, "typ": "ausgabe"}] konvertiert.

Aggregation:

Sonstige_Erträge = Σ(betrag) wo typ == "ertrag" (alle Komponenten + Anlage-Ebene)
Sonstige_Ausgaben = Σ(betrag) wo typ == "ausgabe" (alle Komponenten + Anlage-Ebene)
Sonstige_Netto = Erträge - Ausgaben

Sichtbarkeits-/Doppelzählungs-Regel: Die Aggregation filtert nach aktiv + Laufzeit-Fenster (Anschaffung → Stilllegung) wie jede andere Position; der Caller übergibt das bereits gefilterte sonstige_netto als Skalar an das Finanz-Aggregat. Basis-Positionen zählen genau einmal in die Totals; die T-Konto-Zeilen sind reiner Ausweis (kein zweiter Kostenposten).

Dienstliche Ladekosten: Bei ist_dienstlich == true (E-Auto/Wallbox) werden Ladekosten als kalkulatorische Ausgaben verbucht:

Dienstlich_Ladekosten = Netz_kWh * Wallbox_Preis + PV_kWh * Netzbezugspreis

Warum der PV-Anteil zum Netzbezugspreis zählt, nicht zur Einspeisevergütung (seit 2026-07-31). Die Formel stand bis dahin andersherum, und die beiden für sich plausiblen Halbschritte gingen zusammen nicht auf: Der Eigenverbrauch ändert sich durch das Dienstwagen-Flag nicht — energetisch ist die Ladung Eigenverbrauch hinter dem Zähler —, also schreibt die EV-Ersparnis (Eigenverbrauch × Netzbezugspreis) die dienstlich geladenen kWh voll gut. Der Abzug zog dagegen nur die Einspeisevergütung ab. Netto blieben +22 ct je verschenkter kWh Gewinn stehen (bei 30/8 ct). Die entgangene Einspeisevergütung braucht gar keinen Buchungssatz: sie steckt bereits in der niedrigeren gemessenen Einspeisung. Was einen braucht, ist die zurückzunehmende EV-Gutschrift — und die steht zum Netzbezugspreis.

Gemessen (PV 1.000 · Einspeisung 400 · Netzbezug 100 · 30/8 ct · 200 kWh PV in den Wagen):

FallEigenverbrauchNetto-Ertrag
gar kein Auto (200 kWh eingespeist)400 kWh168,00 €
Privatwagen600 kWh212,00 €
Dienstwagen — bis 2026-07-31600 kWh196,00 €
Dienstwagen — seither600 kWh (unverändert)152,00 €

Die Energiebilanz bleibt unangetastet — Eigenverbrauchs-kWh, Eigenverbrauchsquote und Autarkie ändern sich durch diesen Posten nicht. Korrigiert wurde ausschließlich die Bewertung in Euro.

Netzanteil: Wallbox-Stromvertrag, wenn vorhanden, sonst Anlagentarif — jeweils der Monats-Flexpreis vor dem Stammdaten-Arbeitspreis (P8). Die Aussichten nahmen dafür bis 2026-07-31 den allgemeinen Arbeitspreis, das Cockpit den Wallbox-Preis; Kanon ist das Cockpit.

SoT: core/berechnungen/dienstliche_ladekosten.py (ADR-001). Alle drei Sichten — Cockpit/Übersicht, Aussichten/Finanz-Prognose und der HA-Sensor netto_ertrag_euro — rufen ihn; der HA-Export zog die Kosten bis 2026-07-31 gar nicht ab und stand damit über der Kachel, auf die er sich bezieht.


Prognose-Kanon — „PV-Tagesprognose heute” ist EIN Wert. Der „heute”-Wert (sowie Rest heute, morgen/übermorgen, Vor-/Nachmittag, Stundenprofil) wird seit dem Prognose-Kanon-Fix über einen Service (services/prognose_kanon.py) gebildet und an alle Konsumenten geliefert: Live/Cockpit (live_wetter), die „eedc”-Spalte im Vergleich (api/routes/prognosen), die HA-/MQTT-Sensoren (ha_export_prognose) und den persistierten Tageswert (TagesZusammenfassung.pv_prognose_kwh). Rechenweg: Multi-String-Fan-out pro Orientierungsgruppe (pv_orientation.orientierungs_gruppen → je ein get_solar_prognose) → slot-weise Summe = rohes OpenMeteo-kWh-Profil → eedc-Korrektur pro Energie-Slot (core/berechnungen/prognose_korrektur.korrigiere_tagesprofil, Kaskade korrekturprofil_lookup) mit Invariante Tageswert == Σ Export-Slots. Der Wert rollt mit OpenMeteo, aber überall synchron. Mathematik in core/berechnungen/ (ADR-001), Orchestrierung im Kanon-Service. Symmetrie-Test: tests/test_prognose_kanon.py.

Was seit v4.0.1 zusätzlich am Kanon hängt. Der 14-Tage-Balken in Cockpit → Aussicht samt 14-Tage-Tabelle und den Kacheln „Morgen”/„Summe”/„Ø_Tag”, die Zeilen Morgen/Übermorgen der Live-Solar-Aussicht sowie die Blöcke „Stunden-Prognose”/„Stundenwerte” lasen bis dahin die unkorrigierte OpenMeteo-Zahl bzw. gingen einen eigenen Ein-Abruf-Weg mit der Orientierung einer beliebigen PV-Zeile. Sie kommen jetzt aus derselben Rechnung wie der Prognosen-Vergleich und der Sensor eedc_prognose_day_plus_1_kwh. (Ausgenommen: die Spalte „OpenMeteo” im Prognosen-Vergleich — sie ist der Rohwert und bleibt es.) Fehlt eine Korrektur, bleibt der Wetterdienst-Wert stehen und die Kopfzeile sagt es („Quelle: Open-Meteo (ohne Korrektur)”).

Fällt der Kanon aus (kein OpenMeteo-Ergebnis, keine kWp, Zieltag jenseits des Abruf-Horizonts), springt ein Ersatz-Weg ein — der fächert seit v4.0.1 ebenfalls je Orientierungsgruppe auf statt die Gesamtleistung mit der Orientierung einer beliebigen PV-Zeile zu rechnen (ADR-002/P1: kein Anlagen-Kennwert aus EINER Investition). Liefert eine Gruppe nichts, trägt die Antwort den Hinweis auf die Teilsumme — nicht das Log (ADR-002/P4). Dasselbe gilt, wenn gar keine Prognose vorliegt: 24 Nullen werden als „keine Prognose” ausgewiesen, nicht als Prognose „0 kWh”.

Genauigkeits-Endwert (§6). Das Genauigkeits-Ranking vergleicht IST gegen TagesZusammenfassung.pv_prognose_final_kwh (Fallback pv_prognose_kwh): dieser rollt mit, bis OpenMeteo für den Tag nach Sonnenuntergang konvergiert ist (core/berechnungen/prognose_final.soll_final_einfrieren), und wird dann via pv_prognose_final_at eingefroren. Der Anzeige-Wert bleibt rollend (Drei-Größen-Modell: Anzeige rollend · Lern-Snapshot gefroren · Tracking-Endwert konvergenz-gefroren).

Endpoint: GET /api/aussichten/kurzfristig/{anlage_id} Datenquelle: Open-Meteo (konfigurierbar, siehe Wettermodell-Kaskade)

PV_Ertrag_Tag = GTI_kWh_m2 * Anlagenleistung_kWp * (1 - System_Losses) * Lernfaktor
Wenn Temperatur > 25°C:
Temp_Verlust = (Temperatur - 25) * 0.004
PV_Ertrag_Tag *= (1 - Temp_Verlust)
ParameterQuelleDefault
System_LossesPVGISPrognose.system_losses / 1000.14 (14%)
Anlagenleistung_kWpΣ(PV-Module) + Σ(BKW), gelesen über den SoT-Dispatcher get_erzeuger_kwp (ADR-002/P3). Reihenfolge: Feld Leistung (kWp) der Investition → ersatzweise die Detail-Felder kwp bzw. leistung_kwp (nur Bestands-/Importdaten) → beim Balkonkraftwerk zusätzlich leistung_wp × anzahl (Anzahl fehlt ⇒ 1, nicht die Formular-Vorbelegung 2). Seit v4.0.2 gilt dieselbe Kette an allen Lesestellen der Nennleistung — Prognose, Cockpit, PV-Strings, ROI, CO₂, Live und PDF.Anlage.leistung_kwp
GTI_kWh_m2Global Tilted Irradiance aus Open-Meteo Solar (modul-projiziert mit Tilt + Azimut). Bei Multi-String-Anlagen werden parallele Calls pro Orientierungsgruppe abgesetzt und kWp-gewichtet kombiniert.

GTI-Spalte der 14-Tage-Tabelle ist ein kWp-gewichtetes Mittel („GTI Modulfläche”): die Einstrahlung auf die Modulflächen dieser Anlage, konsistent zur Ertragssumme derselben Zeile (Ertrag ≈ GTI × kWp × (1 − Verluste) ist linear in kWp). Bis v4.0.0 wurde ungewichtet gemittelt — ein 0,8-kWp-Balkonmodul zählte so viel wie ein 12-kWp-Süddach, und die Spalte passte zu keiner anderen Zahl ihrer Zeile. Anlagen mit nur einer Ausrichtung sind nicht betroffen. (Die Performance Ratio läuft über einen anderen Pfad — die Tages-GTI aus der Aggregation, dort schon immer kWp-gewichtet.) | Lernfaktor | Anlagenspezifischer Korrekturfaktor (siehe §4.1c) | 1.0 (vor 7 Tagen Daten) |

GTI vs. GHI: Bis v3.19.x rechnete eedc mit GHI (shortwave_radiation, horizontal). Bei steilen Modulen und tiefstehender Wintersonne ist die Modul-projizierte GTI 2–3× höher — der GHI-basierte „theoretische Ertrag” lag im Winter systematisch zu niedrig (PR-Werte > 1 möglich). Seit v3.20.0 werden GTI-Werte für Prognose und Performance Ratio verwendet.

Multi-String / PV-Parameter-Quelle (v3.20.2/v3.20.3): kWp, Neigung und Azimut werden über den Helper services/pv_orientation.py gelesen, der in dieser Reihenfolge prüft: Top-Level-Spalte der Investition → parameter.{neigung,ausrichtung}_grad (Zahl) → parameter.{neigung,ausrichtung} (Zahl oder String mit Mapping {"süd": 0, "ost": -90, "west": 90, ...}) → Default. Damit liefern alle drei Prognose-Pfade (Energieprofil-Tagesprognose, Aussichten-Kurzfrist, Prefetch-Cache) identische Eingabe-Parameter an Open-Meteo.

Das verwendete Wettermodell ist pro Anlage konfigurierbar (Anlage.wettermodell):

WertModellAuflösungEinsatz
autoBright Sky (DWD) für DE, sonst Open-Meteo best_matchvariabelStandard — maßgeblich ist seit #386 das gepflegte Land, nicht die Koordinaten-Box
meteoswiss_icon_ch2MeteoSwiss ICON-CH22 kmAlpine Standorte CH/AT/IT
icon_d2DWD ICON-D22,2 kmDeutschland (hochauflösend)
icon_euDWD ICON-EU~7 kmEuropa
ecmwf_ifs04ECMWF IFS0,25°Global

Bei einem spezifischen Modell versucht eedc zuerst dieses Modell. Schlägt der Abruf fehl oder liefert es keine Daten für den Standort, fällt es auf best_match zurück (Kaskade). Die verwendete Quelle pro Tag wird im Response als datenquelle-Kürzel (MS/D2/EU/EC/BM) mitgeliefert.

Geltungsbereich (seit v4.0.2, A30): Die Modellwahl wirkt auf alle Prognose-Pfade, weil der Prognose-Kanon (services/prognose_kanon.py) Anlage.wetter_modell an get_solar_prognose durchreicht — also auch auf die eedc-korrigierte Tagesprognose, die Stundenprofile, die Live-/Persistenz-Werte und den HA-/MQTT-Export (services/ha_export_prognose.py). Bis v4.0.1 rechnete dieser Pfad unabhängig von der Einstellung mit best_match, während Live-Wetter, 14-Tage-Wettertabelle und die OpenMeteo-Spalte von /solar-prognose das Modell bereits nutzten — dieselbe Seite zeigte damit zwei Modelle nebeneinander.

Am Rand der Modell-Reichweite entscheidet die Abdeckung, nicht die Existenz (seit v4.0.19, F-36). Jedes Modell hat eine Reichweite (Spalte oben, hinterlegt in WETTER_MODELLE), und am letzten Tag innerhalb dieser Reichweite liefert Open-Meteo häufig nur noch die Stunden bis zum Ende des Modelllaufs. Gemessen am 2026-08-18: icon_d2 (2 Tage) endete um 08:00 — die 15 Stunden danach, also der gesamte Ertragszeitraum, trugen null. Die Kaskade holt für solche Fälle ohnehin zusätzlich best_match, verwarf diesen Tag aber, sobald das gewählte Modell für ihn irgendetwas geliefert hatte:

falsch: Primary gewinnt, wenn der Tag in seiner Antwort VORKOMMT
richtig: Primary gewinnt, wenn er den Tag mindestens so weit ABDECKT wie best_match

Gezählt werden Stunden mit einem GTI-Wert, nicht mit Ertrag — nachts ist GTI 0.0 und damit vorhanden; ein vollständiger Tag hat 24. Bei Gleichstand behält das gewählte Modell den Vorrang, sonst nähme der Fix stillschweigend die Modellwahl weg. Deckt auch best_match den Tag nur teilweise ab, bleibt er teilweise — die fehlenden Felder bleiben leer, statt zu 0 zu werden (ADR-002/P4). SoT: solar_forecast_service._gti_abdeckung_je_tag und _merge_nach_abdeckung; der Ein-Abruf-Zweig (tage ≤ Reichweite) holt best_match in diesem Fall nach, sonst wäre die Regel dort blind.

Diese Lücke war seit dem 2026-07-28 bekannt und wurde zunächst nur umgangen. Die Snapshot-Begrenzung auf den Modellhorizont (wetter/cache.snapshot_days, Auflage E15-a) nimmt der Kaskade die ganz leeren Tage jenseits der Reichweite — den angeschnittenen Tag am Rand lässt sie stehen, denn der liegt ja im Fenster. Die Begrenzung bleibt richtig (sie spart Abrufe und hält den Cache-Kanon), sie ist nur nicht das, was den Fehler verhindert.

„Keine Daten” schließt die leere Antwort ein. Open-Meteo kann für ein Modell mit HTTP 200 antworten und trotzdem für jede Stunde null liefern; _hat_nutzbares_gti behandelt das wie einen Fehlschlag, damit die best_match-Kaskade greift statt einen 0-kWh-Tag zu bauen. Am 2026-07-28 gemessen betrifft das drei der acht wählbaren Werte: ecmwf_ifs04 (HTTP 200, 0 von 72 Stundenwerten gesetzt — das Modell läuft nicht mehr, der Name wird noch akzeptiert) sowie ecmwf_seamless und meteoswiss_seamless (keine gültigen Modellnamen mehr, HTTP-Fehler). Für diese drei rechnet eedc faktisch mit best_match; die Bereinigung der Auswahlliste steht aus.

Endpoint: GET /api/aussichten/kurzfristig/{anlage_id} (SFML-Werte im gleichen Response) Service: services/solar_forecast_service.py Externe API: forecast.solar oder solcast.com (konfigurierbar)

SFML ist eine optionale KI-basierte Prognose-Ergänzung. Sie liefert eine zweite Tages-Prognoselinie neben der eedc-Eigenprognose und den IST-Werten.

1. Externer SFML-Anbieter liefert kWh-Prognose pro Tag
2. Werte werden in DB persistiert (Tabelle: SolarForecastML)
3. Endpoint gibt SFML-Werte zusammen mit eedc-Prognose zurück
{
"datum": "2026-03-28",
"eedc_prognose_kwh": 12.4,
"sfml_prognose_kwh": 11.8,
"ist_kwh": 13.1, // null wenn Zukunft
"datenquelle": "MS" // Wettermodell-Kürzel
}
EEDC_Abweichung (%) = (IST - EEDC_Prognose) / EEDC_Prognose * 100
SFML_Abweichung (%) = (IST - SFML_Prognose) / SFML_Prognose * 100

Beide Abweichungen werden im Frontend als farbige Badges angezeigt (grün = Übererfüllung, rot = Untererfüllung).

4.1c Prognose-Vergleich (Auswertungen → Prognose)

Abschnitt betitelt „4.1c Prognose-Vergleich (Auswertungen → Prognose)“

Anzeige: Auswertungen → Prognose (Genauigkeit gegen IST) — fachlich beschrieben im Handbuch Prognosen.

Endpoint: GET /api/aussichten/prognosen/{anlage_id} Service: api/routes/prognosen.py (in v3.16.6 aus aussichten.py ausgelagert), services/solcast_service.py

Der Prognosen-Tab vergleicht vier Quellen pro Tag/Stunde:

QuelleBedeutung
OpenMeteo (OM)Wetterbasierte Roh-Prognose aus GTI × kWp × (1 − System_Losses). Auch die Stundenkurve „OpenMeteo (roh)” fächert seit v4.0.1 je Orientierungsgruppe auf — vorher rechnete sie die Gesamtleistung so, als hinge sie an einer Dachfläche (Neigung/Ausrichtung der zufällig ersten PV-Zeile, sonst stillschweigend 35° Süd), während der OM-Tageswert derselben Spalte längst auffächerte. Kurve, Summenzeile und Tageswert sind jetzt deckungsgleich.
eedc (kalibriert)die anlagenspezifisch korrigierte Prognose. Legende und Beschriftung sagen seit v4.0.1 „eedc (kalibriert)” statt „eedc (OpenMeteo × Faktor)” — korrigiert wird pro Stunden-Slot auf der Energie, nicht mit einem Tagesfaktor.
SolcastOptionale dritte Quelle, entweder Solcast-API (Free/Paid Key) oder HA-Sensor (BJReplay-Integration). 30-Min-Buckets werden per ceil(bucket_ende) dem Backward-Slot zugeordnet.
ISTTatsächlich gemessener Tageswert aus den Stunden-Snapshots (siehe §6b)

Abweichung und Σ-Zeile im Stundenvergleich (ab v4.0.6)

Abschnitt betitelt „Abweichung und Σ-Zeile im Stundenvergleich (ab v4.0.6)“

Die Tabelle „Stundenvergleich heute” annotiert jeden Prognosewert mit seiner Abweichung zum IST derselben Stunde. Zwei Regeln, beide client-seitig in components/prognose/PrognoseVergleichTeile.tsx (DevBadge bzw. stundenSummeVon), gepinnt in PrognoseVergleichTeile.stundenvergleich.test.tsx:

Δ_Stunde = Prognose_kWh − IST_kWh (angezeigt: |Δ|, Richtung als ▲/▼, „±" wenn |Δ| < 0,05)
Δ_relativ = |Δ| / IST × 100 (nur für die Farbskala bzw. die Σ-Zeile; IST > 0,05 vorausgesetzt)
  • Liegt ein IST vor, wird immer annotiert — auch bei Δ = 0. Eine fehlende Annotation bedeutet damit eindeutig „keine Messung”, nicht „kleine Abweichung”. (Bis v4.0.5 unterdrückte die Anzeige jedes |Δ| < 0,03 kWh; das traf je Spalte unterschiedlich zu und sah in der Zeile aus wie eine Datenlücke — PN Rainer 90004.)
  • Die Σ-Zeile summiert über ein gemeinsames Fenster. Obergrenze ist die letzte Stunde mit IST, höchstens aktuelle_stunde (Slot aktuelle_stunde + 1 läuft noch, s. Backward-Konvention). Stunden ohne IST bleiben in allen vier Spalten außen vor — die vier Summen meinen damit paarweise dieselben Stunden. (Bis v4.0.5 stand dort die 24-Stunden-Prognosesumme neben ist_heute_kwh: mittags z. B. 78,1 gegen 26,1 — die Abweichung maß die Tageszeit.)
  • Ohne jedes IST (Zukunftstag) zeigt die Σ-Zeile die volle Prognosesumme und kein Δ — ADR-002/P4: kein 0-%-Ergebnis auf einer nicht vorhandenen Referenz. Der Vollständigkeits-Grenzfall „alle 24 Stunden gemessen” verhält sich wie vorher (keine bis HH:00-Kennzeichnung).

Nicht zu verwechseln mit der Tagesprognose derselben Spalte (openmeteo_heute_kwh etc.) in der Kennzahl-Matrix — die bleibt der ganze Tag, ebenso verbleibend_* (= IST bisher + Σ Prognose-Slots der Reststunden).

Die eedc-Prognose ist die korrigierte OpenMeteo-Prognose; der Skalar-Lernfaktor unten ist die gröbste Stufe der Korrektur-Kaskade (feiner: Sonnenstand × Wetter je Stunden-Slot, siehe Prognosen §5.3). Der Lernfaktor wird aus historischen (Prognose, IST)-Tag-Paaren berechnet — nur Tage mit gültiger OpenMeteo-Prognose UND IST-Ertrag > 0.5 kWh fließen ein (Schlechtwetter-Tage mit ~0 kWh würden den Faktor sonst verzerren).

faktor = Σ(IST_kWh) / Σ(EEDC_Roh_Prognose_kWh)

Seit v3.16.15 nutzt eedc eine saisonale Kaskade mit den jeweils vorhandenen Daten:

StufeBedingungBezugszeitraum
Monatsfaktor≥ 15 gültige Tage im selben KalendermonatTage des Kalendermonats über alle Jahre
Quartalsfaktor≥ 15 gültige Tage im selben QuartalTage des Quartals über alle Jahre
30-Tage-Fenster≥ 7 gültige TageLetzte 30 Kalendertage
Inaktiv< 7 TageLernfaktor = 1.0, eedc-Spalte gedämpft mit und Tooltip-Verweis

Die aktive Stufe wird im Status-Banner und im KPI-Card-Header angezeigt.

Restzeit-Banner (v3.22.0): Wenn die 7-Tage-Schwelle noch nicht erreicht ist, zeigt das Banner: „X von 7 Tagen, noch Y Tage” — Y berücksichtigt nur Tage mit gültiger Prognose UND IST > 0.5 kWh, also dieselbe Filterregel wie der Faktor selbst.

Persistierung: Lernfaktor pro Quelle separat gecacht. Backfill-Kandidat-Felder (pv_prognose_kwh, Solcast-Tageswerte) werden seit v3.16.14 alle 45 Min automatisch aus dem Prefetch-Job in TagesZusammenfassung geschrieben — vorher hing die Persistierung als Nebeneffekt am Dashboard-Besuch und der Lernfaktor konnte ohne Nutzer-Interaktion nicht berechnet werden.

Genauigkeits-Tracking: MAE + MBE getrennt (v3.22.0, #151)

Abschnitt betitelt „Genauigkeits-Tracking: MAE + MBE getrennt (v3.22.0, #151)“

Über alle Tage mit gleichzeitig verfügbarer Prognose und IST werden zwei Kennzahlen pro Quelle (OM, eedc, Solcast) berechnet, auf vorzeichenbehafteten relativen Fehlern:

err_rel(tag) = (Prognose_kWh - IST_kWh) / IST_kWh
MAE = Ø |err_rel| # Mean Absolute Error — Streuung
MBE = Ø err_rel # Mean Bias Error — systematischer Bias
KennzahlAussage
MAEWie weit liegen Prognose und IST im Schnitt auseinander, unabhängig von der Richtung? Maß für Streuung/Schwankungsbreite.
MBELiegt die Quelle im Mittel über (positiv) oder unter (negativ) dem IST? Bias ist neutral gefärbt — Vorzeichen ist Information, keine Wertung.

MAE/MBE bleiben blind für Asymmetrie: eine Quelle, die in 50 % der Tage 30 % zu hoch und in 50 % der Tage 30 % zu niedrig liegt, hat MAE = 30 % und MBE ≈ 0 % — sie sieht „im Mittel ausgewogen” aus, ist aber nicht mit einem einzigen Lernfaktor korrigierbar. Im Diagnostisch-Modus splittet das Backend die signed errors an 0:

darüber: nur Tage mit err_rel > 0
over_count = Anzahl
over_avg_prozent = Ø err_rel * 100
darunter: nur Tage mit err_rel ≤ 0
under_count = Anzahl
under_avg_prozent = Ø |err_rel| * 100

Response-Schema AsymmetrieEintrag mit Feldern over_count, over_avg_prozent, under_count, under_avg_prozent — pro Quelle als openmeteo_asymmetrie / eedc_asymmetrie / solcast_asymmetrie zurückgegeben.

Tageshälften (Vormittag/Nachmittag) werden nicht hart bei 12:00 Uhr Clockzeit gesplittet, sondern an der astronomischen Tagesmitte (Solar Noon, via Equation of Time + Standortlängengrad). Die Abweichung von 12:00 kann je nach Standort und Datum bis ~30 min betragen. Slots, die Solar Noon enthalten, werden proportional auf VM und NM verteilt — konsistent zum solar_forecast_service.

  • Backward-Slot-Konvention (siehe §6b): Slot N enthält Energie aus dem Intervall [N-1, N).
  • Gerade abgeschlossene Stunde (v3.23.0): wird nicht als Lücke geflaggt — HA Long-Term Statistics schreibt die Stunden-Row erst am Ende der Stunde, das Zeitfenster zwischen Stundenwechsel und HA-Stats-Write (typisch ~5–60 Min) wird mit < (statt <=) toleriert.
  • Echte Lücken (>1 h alt) werden mit ⚠ markiert. Klick auf das Symbol öffnet einen Reparatur-Popover mit „Tag neu berechnen” (POST /api/energie-profil/{anlage_id}/reaggregate-tag) und einem Fallback-Link zur Datenquellen-Zuordnung.

Endpoint: GET /api/aussichten/langfristig/{anlage_id}

PVGIS_kWh = PVGISPrognose.monatswerte[monat].e_m
(Fallback: TMY * kWp * 0.85)
Monat_PR = Ø(IST / SOLL) für diesen Monat aus historischen Daten
Gesamt_PR = Ø(alle monatlichen Performance Ratios)
Trend_kWh = PVGIS_kWh * Monat_PR
Konfidenz_Min = Trend_kWh * 0.85 (15% Band)
Konfidenz_Max = Trend_kWh * 1.15
Trend-Richtung:
> 1.05 → "positiv"
< 0.95 → "negativ"
sonst → "stabil"

Endpoint: GET /api/aussichten/trend/{anlage_id}

Primär: Vollständige Jahre (12 Monate)

Wenn >= 2 vollständige Jahre vorhanden:
Änderung = (Letztes_Jahr_kWh - Erstes_Jahr_kWh) / Erstes_Jahr_kWh * 100
Degradation = Änderung / Anzahl_Jahre

Fallback: TMY-Ergänzung (>= 6 Monate pro Jahr)

Wenn >= 2 Jahre mit jeweils >= 6 Monaten Daten:
1. Performance-Ratio aus vorhandenen Monaten berechnen
2. Fehlende Monate mit TMY * PR ergänzen
3. Degradation aus ergänzten Jahreswerten ableiten

Endpoint: GET /api/aussichten/finanzen/{anlage_id}

Bisherige_Erträge = Σ(Einspeisung * Vergütung / 100) (PV)
+ Σ(Eigenverbrauch * Netzbezug_Preis / 100) (EV)
+ WP_Ersparnis (vs. Gas)
+ E-Auto_Ersparnis (vs. Benzin)
+ BKW_Ersparnis (Eigenverbrauch)
+ Sonstige_Netto (alle Investitionstypen)
Für jeden Monat mit WP-Daten:
Gas_Preis = Monatsdaten.gaspreis_cent_kwh # ab v3.21.0, wenn pro Monat gepflegt
∨ Investition.parameter.alter_preis_cent_kwh # Fallback statisch
Gas_Kosten = (Heizung + WW) / 0.9 * Gas_Preis / 100
+ alternativ_zusatzkosten_jahr / 12 # Zusatzkosten anteilig pro Monat
WP_Netzkosten = Strom * 0.5 * WP_Preis / 100 # 50% Netzanteil-Annahme
Ersparnis = Gas_Kosten - WP_Netzkosten

Monats-Gaspreis (v3.21.0): Wenn Monatsdaten.gaspreis_cent_kwh pro Monat gepflegt ist, wird er Monat für Monat verwendet — ein Tarifwechsel ändert dann nicht mehr rückwirkend die ganze Historie. Ohne Eintrag bleibt es beim statischen alter_preis_cent_kwh der Investition. Pflege in der assistierten MonatsdatenForm (über BEDINGTE_BASIS_FELDER mit bedingung_basis: hat_waermepumpe) — in V4 der EINE Erfassungsweg; der frühere Monatsabschluss-Wizard ist als V4-Fläche stillgelegt und läuft nur noch über die V3-Route (bis zum Flip).

Benzin_Liter = Σ(km) / 100 * Vergleich_L_100km
Benzin_Kosten = Benzin_Liter * Benzinpreis
Netzstrom_Kosten = Σ(ladung_netz_kwh) * Strompreis / 100
Ersparnis = Benzin_Kosten - Netzstrom_Kosten

Für jeden Prognosemonat:

PV_kWh = PVGIS_Monatswert (oder TMY * kWp * 0.85)
Basis_EV = PV_kWh * Basis_EV_Quote (historisch ermittelt, 15-70%)
Speicher_Beitrag = Ø_Speicher_Entladung * PV_Faktor
V2H_Beitrag = Ø_V2H_Entladung (konstant)
WP_PV_Anteil = WP_Strom * 0.5 * sqrt(PV_Faktor)
Eigenverbrauch = min(Basis_EV + Speicher + V2H + WP_PV, PV_kWh)
Einspeisung = PV_kWh - Eigenverbrauch
Netto_Ertrag = Einspeisung * Vergütung/100 + Eigenverbrauch * Preis/100

WP saisonal gewichtet:

WP_SAISON_FAKTOREN = {Jan: 1.8, Feb: 1.6, Mär: 1.3, Apr: 0.8, Mai: 0.4, Jun: 0.2,
Jul: 0.2, Aug: 0.2, Sep: 0.4, Okt: 0.8, Nov: 1.3, Dez: 1.7}
WP_Strom_Monat = WP_Strom_Durchschnitt * Saison_Faktor

SoT: core/berechnungen/amortisation.py::berechne_amortisations_fortschritt (Berechnungs-Layer, ADR-001) — dieselbe Formel speist die Kachel „Amortisations-Fortschritt” in Auswertungen → ROI.

Investition = Σ max(0, Anschaffung − Alternative) # relevante Kosten, §3.6
Jahres_Netto_Ertrag = PV_Einspeise_Erlös + EV_Ersparnis
+ WP_Ersparnis + E-Auto_Ersparnis
+ BKW_Ersparnis + Sonstige_Netto
- Betriebskosten_Jahr
[- USt_Eigenverbrauch]
ROI_Fortschritt (%) = Bisherige_Erträge / Investition * 100
Amortisation_erreicht = Bisherige_Erträge >= Investition
Wenn nicht amortisiert:
Rest_Betrag = Investition - Bisherige_Erträge
Monate_bis_Amort = Rest_Betrag / (Jahres_Netto_Ertrag / 12)
Prognose_Jahr = Heute + Monate_bis_Amort

Der Fortschritt ist reine MessungJahres_Netto_Ertrag geht ausschließlich in die Restlaufzeit ein. Ist er ≤ 0 (Anlaufjahr), gibt es keine Prognose statt einer geratenen; sind die relevanten Kosten 0, gibt es nichts zu amortisieren (0 %, nicht „fertig”). Der Fortschritt ist nicht bei 100 % gedeckelt: eine Anlage, die sich doppelt bezahlt gemacht hat, darf das sagen.


Funktion: lade_tarife_fuer_anlage() in strompreise.py

  1. Alle gültigen Tarife laden (gueltig_ab <= heute UND (gueltig_bis IS NULL ODER gueltig_bis >= heute))
  2. Nach verwendung gruppieren (neuester zuerst)
  3. Fallback-Kette:
waermepumpe → waermepumpe-Tarif || allgemein
wallbox → wallbox-Tarif || allgemein
allgemein → allgemein-Tarif || Hardcoded Defaults (30.0 / 8.2)
KomponenteTarif-KeyPreis-Feld
PV Einspeisung/EVallgemeineinspeiseverguetung_cent_kwh, netzbezug_arbeitspreis_cent_kwh
Wärmepumpe Stromwaermepumpenetzbezug_arbeitspreis_cent_kwh
Wallbox/E-Auto Ladungwallboxnetzbezug_arbeitspreis_cent_kwh
Grundpreisallgemeingrundpreis_euro_monat
Netzbezug_Preis = 30.0 ct/kWh
Einspeisevergütung = 8.2 ct/kWh
Grundpreis = 0 EUR/Monat

Für Nutzer mit dynamischem Stromtarif (z.B. Tibber, aWATTar) kann der tatsächliche monatliche Durchschnittspreis verwendet werden statt des festen Tarifpreises.

Fallback-Kette für netzbezug_preis_cent:

1. Monatsdaten.netzbezug_durchschnittspreis_cent (manuell pro Monat)
2. HA-Sensor strompreis (via Datenquellen-Zuordnung) (automatisch aus HA)
3. Strompreis.netzbezug_arbeitspreis_cent_kwh (fester Tarif)
4. Hardcoded Default: 30.0 ct/kWh

Konfiguration:

Der Jahres-Ø ist mengengewichtet. Die Jahres-/Gesamt-Kachel „Ø-Preis Netz” (und ebenso die Ø-Einspeisevergütung) rechnet Σ(Preis_Monat × Menge_Monat) / Σ Menge_Monat — nicht das arithmetische Mittel der Monatspreise. Gewichtet wird der effektive Monatspreis (also der Ø-Bezugspreis vor dem Tarif-Arbeitspreis, dieselbe Kette wie oben); sonst fiele ein Jahr mit dynamischem Tarif auf den Referenzpreis zurück, obwohl die Kosten darunter mit dem Stundenpreis gerechnet sind. Monate ohne Menge fallen aus Zähler und Nenner; gibt es im Zeitraum überhaupt keine Menge, bleibt das arithmetische Mittel als Rückfall stehen, statt die Kachel zu leeren. Ohne die Gewichtung passte der Kopfwert nicht zu den kWh und Euro darunter — ein teurer Winter- und ein billiger Sommermonat wogen gleich viel (Forum simon42 #89667/67).


6. Investitionstyp-spezifische Berechnungen (ROI-Dashboard)

Abschnitt betitelt „6. Investitionstyp-spezifische Berechnungen (ROI-Dashboard)“

Endpoint: GET /api/investitionen/roi/{anlage_id}

Pass 1: Alle Wechselrichter identifizieren → pv_systeme[wr_id]
Pass 2: PV-Module via parent_investition_id zuordnen,
DC-Speicher via parent_investition_id zuordnen
Pass 3: Verbleibende Investitionen → standalone
PV-Einsparung wird proportional nach kWp auf Module verteilt:
Modul_Einsparung = Gesamt_PV_Einsparung * (Modul_kWp / Gesamt_kWp)
Wenn weniger als 12 Monate Daten:
1. Versuche PVGIS-gewichtete Hochrechnung:
Faktor = PVGIS_Jahressumme / PVGIS_Summe_vorhandene_Monate
2. Fallback: Lineare Hochrechnung:
Faktor = 12 / Anzahl_Monate

6b. Energieprofil-Berechnungen (Tages-Aggregation)

Abschnitt betitelt „6b. Energieprofil-Berechnungen (Tages-Aggregation)“

Service: services/energie_profil_service.py, services/sensor_snapshot_service.py Trigger: Scheduler stündlich :05 (Snapshot) und :55 (Live-Preview), täglich 00:15 (Vortag-Aggregation) + Monatsabschluss (Backfill + Rollup)

Stunden-kWh werden nicht mehr aus 10-Min-Leistungs-Samples integriert (±5–15 % Drift), sondern als Differenz kumulativer Zähler-Snapshots berechnet — analog zum HA Energy Dashboard.

1. Stündlicher Snapshot-Job (Cron :05) schreibt pro Anlage und gemapptem
kWh-Sensor den aktuellen Zählerstand in die Tabelle `sensor_snapshots`.
Quellen: HA Long-Term Statistics (Add-on)
oder MQTT-Energy-Snapshots (Standalone/Docker).
2. :55-Live-Preview (v3.21.0) schreibt zum Stundenende einen Zählerstand
für die anstehende volle Stunde — laufende Stunde sofort sichtbar
statt erst um (h+1):05.
3. Tagesaggregation (00:15) bildet Differenzen: kWh[h] = snap[h] - snap[h-1]
für h = 0..23 (Snapshot-Range -1..23, damit Slot 0 aus Vortag-23:00 fließt).

Snapshot-Lücken-Interpolation (v3.20.0, #145):

Wenn ein Snapshot fehlt (Scheduler-Ausfall, HA-Statistics-Timeout, MQTT-Cache leer), interpoliert eedc linear zwischen den vorhandenen Nachbar-Stunden:

Beispiel: snap[10] = 1500 kWh, snap[11] = None, snap[12] = 1505 kWh
→ interpoliert: snap[11] = 1502.5 kWh
→ kWh[11] = 2.5, kWh[12] = 2.5 (statt fälschlich kWh[11]=0, kWh[12]=5 als Spike)

Ränder (h0 fehlend am Tagesanfang, h24 am Tagesende) werden nicht extrapoliert — der Wert bleibt None und die betroffene Stunde fällt aus der Delta-Bildung. Tagessumme bleibt in jedem Fall korrekt (snap[24] − snap[0]).

HA-Statistics-Toleranz (v3.20.0, #145): Reduziert von 120 min auf 10 min. Wenn die Zielstunde in HA-Statistics noch nicht vorhanden ist, schreibt der Job nichts (statt einen Nachbar-Wert zu liefern, der Slot N als 0 und Slot N+1 als 2-Stunden-Delta entstehen ließ). Der nächste aggregate_day-Lauf 15 Min später holt den Wert via Self-Healing nach.

Restart-Recovery (v3.23.0): Beim Scheduler-Start läuft sensor_snapshot_startup_recovery() im Hintergrund — holt für die letzten 6 Stunden je Anlage HA-Statistics-Snapshots (idempotent dank Upsert) plus für die laufende Stunde einen Live-Snapshot, anschließend aggregate_today_all.

Tagesreset-Heuristik (v3.23.0): HA-utility_meter-Sensoren mit täglichem Reset werfen um Mitternacht ein stark negatives Delta. Erkannt am Muster s1 < 0.5 ∧ s0 > 0.5, eedc nimmt dann max(0, s1) als Slot-0-Wert (Energie seit Reset, typ. ≈ 0 nachts). Bei untypischen negativen Deltas mitten am Tag bleibt die Reset-Warnung wie bisher.

Phase D Cleanup (v3.21.0, #138): Seit v3.21.0 ist der Zähler-Snapshot-Pfad die einzige kWh-Quelle. Der frühere W-Integration-Fallback (_val()-Helper, else-Branch in backfill_from_statistics) und das Feature-Flag EEDC_ENERGIEPROFIL_QUELLE sind entfernt. Auf Anlagen ohne kumulative Zähler erscheinen Stunden-kWh-Felder als NULL statt geschätzter Werte.

Alle Stunden-Slots im Energieprofil und in den Prognose-Quellen folgen seit v3.20.0 der Backward-Konvention:

KonventionSlot N enthält Energie aus …
Backward (eedc, ab v3.20.0)[N-1, N) — „die letzte Stunde”. Slot 0 = Energie 23:00–24:00 des Vortags.
Forward (Strompreis, weiterhin)[N, N+1) — „gilt ab jetzt”. Industrieüblich für aWATTar/Tibber/EPEX.

Industriestandard für Energie: HA Energy Dashboard, SolarEdge, SMA, Fronius, Tibber.

Migration auf Backward (v3.20.0):

  • sensor_snapshot_service.get_hourly_kwh_by_category: Delta snap[h] − snap[h-1] → Slot h (vorher: snap[h+1] − snap[h] → Slot h)
  • solcast_service (API + HA-Sensor): 30-Min-Buckets per ceil(bucket_ende) → richtigen Backward-Slot. Ein Bucket am Tagesübergang [23:00, 23:30) heute landet damit korrekt in Slot 0 des Folgetags, nicht in Slot 0 von heute.
  • Nach Update auf v3.20.0 nötig: einmal „Verlauf nachberechnen + überschreiben” auslösen, damit alle historischen Stundenwerte umverteilt werden. Tagessummen und alle abgeleiteten Kennzahlen (Autarkie, PR, Lernfaktor) sind konventionsunabhängig korrekt.

Die Konvention endet nicht am Backend (v4.0.6). Wer eine Stunde beschriftet oder einen Messwert in eine Chart-Spalte einsortiert, folgt derselben Regel — Client-SoT ist frontend/src/lib/stundenSlot.ts (Spiegel von core/berechnungen/slot_konvention.py, Regressionstest stundenSlot.test.ts):

FunktionWofür
slotZeitspanne(h)Beschriftung — Slot 11 → „10:00–11:00 Uhr”. Jeder Tooltip einer Stundengrafik.
slotAusIntervallStart(h)Messreihen mit Slot-Beginn-Stempel (10-Min-Punkte des Tagesverlaufs) → Slot h+1. Rückgabe 24 = Slot 0 des Folgetags, kein Modulo.
slotAusZeitpunkt("HH:MM")Zeitpunkt-Marker (Sonnenaufgang, Solar Noon) in den Slot, der ihn enthält — 05:56 → Slot 6.

Auslöser war Rainer (PN 90106, 01.08.2026): der Live-Block „Wetter heute” baute seine Zeitspanne selbst und vorwärts ([h, h+1)), während die Prognose-Sicht rückwärts beschriftete; zusätzlich bündelte er die 10-Minuten-Punkte der Stunde h in Spalte h statt h+1. Gemessen gegen die Live-Box lag die IST-Kurve dadurch genau eine Spalte neben der Prognose im selben Chart. Klasse: nicht das Beschriftungs-Symptom patchen, sondern die Stelle, die die Zuordnung konstruiert (feedback_aggregations_drift).

Auch die Verbrauchs-Seite folgt ihr (v4.0.6). Die gestrichelte Verbrauchs-Prognose im Live-Chart kommt aus dem individuellen Verbrauchsprofil der letzten 7 vollen Tage (services/live_verbrauchsprofil_service.py), und das hat drei Quellen mit Vorrang in dieser Reihenfolge:

QuelleWann sie greiftSlot-Herkunft
EEDC-DB (TagesEnergieProfil.stunde)sobald der Scheduler mindestens zwei Tage aggregiert hatschon backward (Aggregator schreibt über lts_boundary_index)
HA-History (Leistungsmittel je Stunde)frische Installation, noch keine DB-Historie_slot_fensterbackward_slot_aus_period_start
MQTT-Snapshots (Zähler-Delta je Stunde)Standalone-Betrieb ohne HAdieselbe Stelle

Bis v4.0.5 bündelten die beiden Fallback-Quellen forward: die Energie aus [h, h+1) landete unter Index h, das Profil lag also eine Stunde zu früh — unsichtbar für jeden, der schon DB-Historie hat, sichtbar bei Neuinstallation und im Standalone-Betrieb. Seit v4.0.6 ordnet eine Stelle im Modul zu (_slot_fenster), und zwar über den Backend-SoT core/berechnungen/slot_konvention.py; das Abfrage-Fenster beginnt dafür eine Stunde vor Mitternacht, weil Slot 0 des ersten Tages das Intervall [Vortag 23:00, 00:00) trägt. Gepinnt in tests/test_verbrauchsprofil_slot_konvention.py — je eine Regression pro Fallback-Pfad plus ein Symmetrie-Test „gleiche Wirklichkeit, drei Messarten ⇒ ein Profil” (feedback_aggregator_symmetrie).

Die Zuordnung allein genügt nicht — die drei Quellen müssen sich auch einig sein, wann eine Stunde überhaupt gemessen wurde. Eine unvollständige Stunde liefert in allen dreien keine Stichprobe; sie wird ausgelassen, nicht geschätzt und nicht als 0 kW gezählt:

QuelleStunde gilt als gemessen, wenn …sonst
EEDC-DBeine TagesEnergieProfil-Zeile mit verbrauch_kw IS NOT NULL existiertkeine Zeile ⇒ keine Stichprobe (galt schon immer)
HA-Historymindestens ein Netz-Sensor (Bezug, Einspeisung oder Kombi) in dieser Stunde einen Messpunkt hatStunde wird übersprungen
MQTT-Snapshotsfür jeden gelieferten Zähler an beiden Intervallgrenzen ein Stand vorliegt (höchstens 6 Minuten alt)Stunde wird übersprungen

Zu den beiden Fallbacks im Einzelnen:

  • MQTT misst über die Intervallgrenzen, nicht über die zufällig in der Stunde liegenden Snapshots. Bis v4.0.5 war das Stundendelta „letzter minus erster Snapshot innerhalb der Stunde”; das letzte Snapshot-Intervall fiel damit jede Stunde heraus — bei 5-Minuten-Takt rund 8 % zu wenig, und ein Verbrauch, der erst gegen Ende der Stunde anfiel, ging ganz verloren. Der Randwert ist der letzte Zählerstand bei oder vor der Grenze; benachbarte Stunden lesen denselben Wert, deshalb geht an der Grenze nichts verloren und nichts wird doppelt gezählt. Der Scheduler schreibt alle 5 Minuten, aber nicht auf die volle Stunde gerastert — daher die 6 Minuten Toleranz (Reserve für Verzug, aber nicht genug für einen ausgefallenen Snapshot).
  • HA zählt eine Stunde ohne Historie nicht mehr als gemessene Null. Bis v4.0.5 hängte jede Stunde eine Stichprobe an, auch wenn der Recorder nichts geliefert hatte: aus unbekannt wurde war nichts, und ein einziger Ausfalltag drückte jeden Werktags-Slot auf 4/5 des wahren Werts. Beleg ist bewusst der Netzanschluss und dort any statt all: PV- und WP-Sensoren melden nachts bzw. im Stillstand stundenlang keine Zustandsänderung, ohne dass Daten fehlen, und von Bezug und Einspeisung bewegt sich immer genau einer. Liefert im ganzen Fenster kein Netz-Sensor, gibt es kein HA-Profil — sonst stünde die reine PV-Kurve als vermeintlicher Verbrauch da.

Ein Slot ohne jede Stichprobe fehlt im Ergebnis-Dict. Der Konsument (api/routes/live_wetter.py::_berechne_verbrauchsprofil) erkennt das und setzt seine Standard-Grundlast ein, statt still 0 kW anzunehmen — die lokale Ausprägung von ADR-002/P4.

PV_kWh = snap_pv[h] - snap_pv[h-1] # für jede gemappte PV-Investition
Einspeisung_kWh = snap_einspeisung[h] - snap_einspeisung[h-1]
Netzbezug_kWh = snap_netzbezug[h] - snap_netzbezug[h-1]
Bat_Ladung_kWh = snap_ladung[h] - snap_ladung[h-1]
Bat_Entladung_kWh = snap_entladung[h] - snap_entladung[h-1]
Verbrauch_kWh = PV + Netzbezug + Bat_Entladung - Einspeisung - Bat_Ladung
Überschuss_kWh = max(0, PV - Verbrauch_kWh - Bat_Ladung)
Defizit_kWh = max(0, Verbrauch_kWh + Bat_Ladung - PV)

Strikte NULL-Semantik: Wenn ein Zähler nicht gemappt ist, bleibt das zugehörige Feld NULL (statt aus Leistungs-Samples zu schätzen). Im Frontend zeigt eedc ein ⚠-Badge bei Datenlücken — siehe Reparatur-Popover in §4.1c.

Peaks aus W-Integration (für Spitzenwerte):

Peak_PV_kW = max(W-Sample) / 1000 # 10-Min-Auflösung
Peak_Netzbezug_kW = max(W-Sample) / 1000
Peak_Einspeisung_kW = max(W-Sample) / 1000

Peaks brauchen die Leistungssamples — die kWh-Aggregation läuft separat über die Snapshots.

Zusätzliche Daten pro Stunde:

  • Temperatur + Globalstrahlung + GTI: Open-Meteo Historical API (Archiv) bzw. Forecast API (heute), inkl. global_tilted_irradiance mit Modul-Tilt/Azimut
  • Stunden-Aggregation IST von Wetter-Samples: seit v3.23.6 als arithmetisches Mittel der 10-Min-Slots (vorher „last”) — konsistent mit der Mean-Konvention der Open-Meteo-Stundenwerte, behebt einen ~25-min-Versatz im Live-Heute-Chart.
  • Batterie-SoC: HA Sensor History (Stundenmittel) — gefiltert auf inv.typ == "speicher", siehe Vollzyklen-Hinweis unten
  • Strompreis (zwei Felder): strompreis_cent (Endpreis aus HA-Sensor) + boersenpreis_cent (EPEX, immer befüllt)
Überschuss_kWh = Σ(Überschuss_kWh) alle 24 Stunden
Defizit_kWh = Σ(Defizit_kWh)
Peak_PV_kW = max(PV_kW) über alle Stunden
Peak_Netzbezug_kW = max(Netzbezug_kW)
Peak_Einspeisung_kW = max(Einspeisung_kW)
Temperatur_Min/Max = min/max(Temperatur_C) aus Open-Meteo
Strahlung_Summe_Wh_m2 = Σ(Globalstrahlung_W/m²) × 1h
GTI_Summe_Wh_m2 = Σ(global_tilted_irradiance) × 1h # ab v3.20.0

Batterie-Vollzyklen (v3.22.0 verschärft):

Δ_SoC_Summe = Σ |SoC[h] - SoC[h-1]| für h = 1..23,
AUSSCHLIESSLICH aus Investitionen mit typ == "speicher"
Vollzyklen = Δ_SoC_Summe / 200 # 0→100→0 = 200 % = 1 Vollzyklus

E-Auto-SoC-Trennung: Vor v3.22.0 nahm _get_soc_history den ersten live.soc-Sensor aus den Investitionen — bei Anlagen mit E-Auto landete dessen SoC zuerst in der Liste, der eigentliche stationäre Speicher wurde nicht angefasst. Folge: batterie_vollzyklen reflektierten den ΔSoC des Autos. Seit v3.22.0 filtern beide Selektions-Pfade (_get_soc_history, Bulk-Fetch in backfill_from_statistics) auf inv.typ == "speicher". Nach Update auf v3.22.0: einmal „Verlauf nachberechnen + überschreiben” auslösen.

Performance Ratio (v3.20.0 auf GTI):

Theoretisch_kWh = GTI_Wh_m2 × kWp / 1000 # ab v3.20.0
Performance_Ratio = PV_Ertrag_kWh / Theoretisch_kWh
# Vor v3.20.0 (deprecated, GHI-basiert):
# Theoretisch_kWh = Strahlung_Wh_m2 × kWp / 1000 # horizontale Globalstrahlung

Bei Multi-String-Anlagen werden GTI-Werte pro Orientierungsgruppe parallel abgerufen und kWp-gewichtet kombiniert (analog Live-Wetter-Pfad). Ohne gemappte PV-Module bleibt PR bewusst None statt einen verzerrten GHI-Wert zu melden.

Validation Winterborn 2025-12-28: GHI 1317 Wh/m² vs. GTI Süd35° 3358 Wh/m² (Faktor 2.55×). PR vorher 2.16 (physikalisch unmöglich), nachher 0.85 (plausibel für einen kalten Wintertag). Betrifft historische TagesZusammenfassung.performance_ratio, MonatsAuswertungResponse.performance_ratio_avg und die PR-Spalte im PDF-Jahresbericht — nach Update einmalig „Verlauf nachberechnen + überschreiben” auslösen. PV-kWh-Werte selbst bleiben unverändert.

§51 EEG (Negativpreis-Analyse):

boersenpreis_avg = Ø(boersenpreis_cent[h])
boersenpreis_min = min(boersenpreis_cent[h])
neg_stunden = Anzahl h mit boersenpreis_cent[h] < 0
einspeisung_neg_preis_kwh = Σ(Einspeisung_kWh[h]) für h mit boersenpreis_cent[h] < 0

Datengrundlage für die §51-Sektion in Cockpit → Monat (die Monats-Darstellungen des alten Energieprofils sind dorthin gehoben — siehe Handbuch Energieprofil).

WP-Kompressor-Starts (v3.24.0, #136):

TagesEnergieProfil.wp_starts_anzahl[h] = snap_starts[h] - snap_starts[h-1]
# Summe aller WP-Investitionen pro Stunde
TagesZusammenfassung.komponenten_starts = {"wp_starts_anzahl": {"<inv_id>": <int>, ...}}
# Tages-Differenz pro WP-Investition

Architektur trennt Counter-Felder strikt von kWh-Feldern in KUMULATIVE_COUNTER_FELDER, damit reine Counter nicht versehentlich in die Energie-Bilanz fließen. Vollbackfill aus HA Long-Term Statistics greift für Tages-Summen (Faktor 1.0 statt 0.001 bei unbekannter Einheit). Stunden-Detail wird ab Live-Erfassung gefüllt.

Day-Ahead-Stundenprofil-Snapshot (v3.23.4, intern):

Zwei JSON-Felder in TagesZusammenfassung (pv_prognose_stundenprofil, solcast_prognose_stundenprofil) speichern den ersten OpenMeteo-/Solcast-Forecast des Tages als 24-Werte-Liste in kWh (Backward-Slot). First-write-wins: spätere Aufrufe am selben Tag überschreiben das Profil nicht. Reine Hintergrund-Datensammlung für künftige Diagnostik (Korrekturprofil-Konzept). Speicher ~80 KB/Jahr/Anlage.

Aggregiert alle TagesZusammenfassung eines Monats in Monatsdaten-Felder:

Monatsdaten-FeldAggregationBeschreibung
ueberschuss_kwhΣ(Tages-Überschuss)Monatlicher PV-Überschuss
defizit_kwhΣ(Tages-Defizit)Monatliches Energie-Defizit
batterie_vollzyklenΣ(Tages-Vollzyklen)Monatliche Batterie-Zyklen
performance_ratioØ(Tages-PR)Durchschnittliche Performance Ratio
peak_netzbezug_kwmax(Tages-Peak)Maximaler Netzbezug im Monat

Auslöser: Wird beim Monatsabschluss nach backfill_range() aufgerufen, um fehlende Tage nachzuberechnen (begrenzt durch HA-History ~10 Tage).


SymptomMögliche UrsachePrüfung
Autarkie zu hoch/niedrigFalsche Einspeisung/Netzbezug-WerteMonatsdaten prüfen - sind die Zählerwerte plausibel?
EV-Quote > 100%Speicher-Entladung > PV-ErzeugungInvestitionMonatsdaten für Speicher prüfen
Netto-Ertrag = 0Kein Tarif angelegtStrompreis-Tabelle prüfen
WP-Ersparnis fehltKein WP-Spezialtarif, falscher Gas-PreisTarife prüfen; hardcodierter Gas-Preis 10ct im Cockpit
ROI weicht ab (Cockpit vs Investitionen)Verschiedene BerechnungswegeCockpit: kumuliert; Investitionen: p.a. mit calculations.py
SOLL überhöhtTeil-Jahr ohne faire Vergleichsbasismonths_with_data prüfen (ab v2.3.2 behoben)
PV-Erzeugung = 0Legacy-Feld statt InvestitionMonatsdatenPrüfen ob PV-Module als Investitionen angelegt sind
Dienstl. Wallbox in E-Mobist_dienstlich nicht gesetztInvestition.parameter["ist_dienstlich"] prüfen
USt wird nicht abgezogenSteuerliche Behandlung falschAnlage.steuerliche_behandlung muss regelbesteuerung sein
Spezialtarif greift nichtFalsche verwendung oder abgelaufenStrompreis.verwendung und gueltig_ab/bis prüfen

Schritt 1: Eingabedaten prüfen

API: GET /api/monatsdaten/aggregiert/{anlage_id}
→ Zeigt Monatsdaten + InvestitionMonatsdaten zusammen

Schritt 2: Tarife prüfen

API: GET /api/strompreise?anlage_id={id}&aktuell=true
→ Zeigt alle gültigen Tarife mit Verwendung

Schritt 3: Berechnungsergebnis prüfen

API: GET /api/cockpit/uebersicht/{anlage_id}?jahr=2025
→ Alle aggregierten KPIs für ein Jahr
API: GET /api/investitionen/roi/{anlage_id}
→ ROI pro Komponente mit Detail-Berechnung

Schritt 4: Prognose-Basis prüfen

API: GET /api/cockpit/prognose-vs-ist/{anlage_id}?jahr=2025
→ PVGIS SOLL vs tatsächliche IST-Werte
API: GET /api/cockpit/pv-strings/{anlage_id}?jahr=2025
→ SOLL-IST pro PV-Modul (mit Performance Ratio)
  1. JSON-Felder in SQLAlchemy: Änderungen an verbrauch_daten oder parameter werden nur persistiert mit flag_modified(obj, "feldname")
  2. 0-Werte: if val: wertet 0 als False aus → immer if val is not None: verwenden
  3. Legacy-Felder: Monatsdaten.batterie_* ist deprecated. Monatsdaten.pv_erzeugung_kwh ist es nicht — kein Schreibziel für neuen Code und nur als Eingang von resolve_pv_je_modul zu lesen (Anlagen-Aggregat, s. Schicht 1); Pro-Modul-Werte kommen aus InvestitionMonatsdaten (Typ: pv-module)
  4. PVGIS E_m vs e_m: Ältere Prognosen verwenden E_m (Großbuchstabe), neuere e_m
  5. Grundpreis: Wird zu den Netzbezugskosten addiert, NICHT vom Netto-Ertrag abgezogen. Er ist auch der Grund, warum „Kosten ÷ kWh” nicht den Ø-Preis ergibt — dafür gibt es netzbezug_arbeitspreis_kosten_euro (s. §3.1)
  6. Cockpit vs ROI-Dashboard: Cockpit berechnet inline (vereinfacht), ROI-Dashboard nutzt calculations.py (detaillierter)

Letzte Aktualisierung: 2026-07-25 (v4.0)