Esta página aún no está disponible en este idioma. Estás viendo la versión original.
Methodik & Annahmen
Konventionen (gelten überall)
- Azimut: 0° = Nord, 90° = Ost, 180° = Süd, 270° = West (NOAA-Standard).
- Elevation: Höhe der Sonne über dem Horizont (0..90°).
- Tilt: Modul-Neigung gegen die Horizontale (0° = liegend, 90° = senkrecht).
- Pure Dart: Modell hängt von keinen Flutter-/Plugin-APIs ab und ist deterministisch testbar.
- Year-1: Werte beziehen sich auf das erste Betriebsjahr ohne Modul-Degradation.
- kWh/Jahr: Sonderzeichen
kWhdurchgängig groß/klein wie hier; nie “KWh” oder “kwH”.
Notation & Symbole
Diese Übersicht macht jede Formel im Dokument selbsterklärend. Wenn ein Symbol unten steht, wird es in der jeweiligen Sektion ohne erneute Erklärung verwendet.
Code-Konvention. Die App rechnet in ASCII (alpha, gamma, tilt), dieses Doc nutzt griechische Buchstaben zur besseren Lesbarkeit. Winkel werden überall in Grad geführt und intern (innerhalb der .dart-Funktion) in Radiant umgerechnet. Mengen führen ihre Einheit als Suffix (kWh/Jahr, W/m², °C) oder in eckigen Klammern (loss [%], E [kWh]).
| Symbol | Einheit | Bedeutung | Erste Verwendung |
|---|---|---|---|
| φ | ° | Latitude (geographische Breite) | §1 |
| δ | rad | Sonnen-Deklination | §1 |
| H | ° | Stundenwinkel | §1 |
| z | ° | Zenitwinkel (= 90° − α) | §1 |
| α | ° | Sonnen-Elevation (Höhe über Horizont) | §1 |
| γ | ° | Sonnen-Azimut (0=N, 90=O, 180=S, 270=W) | §1 |
| Γ | rad | Fractional Year (Jahres-Position als Winkel) | §1 |
| β | ° | Modul-Neigung gegen Horizontale (0°=liegend, 90°=senkrecht) | §3, §8 |
| γ_p | ° | Modul-Azimut (Ausrichtung der Modul-Normalen) | §3, §8 |
| AoI | ° | Angle of Incidence (Winkel Sonnenstrahl ↔ Modul-Normal) | §3, §8 |
| f_diff | — | Diffus-Fraktion: DHI/GHI als Jahresmittel pro Land | §3, §0.5 |
| SVF | — | Sky-View-Factor: unverschatteter Anteil des Himmelsdoms (0..1) | §3 |
| L_direct | % | Direkt-Verlust durch Verschattung | §3 |
| L_diffuse | % | Diffus-Verlust durch Verschattung | §3 |
| L | % | Kombinierter Verschattungsverlust | §3, §11 |
| EqTime | min | Equation of Time (Zeitgleichung) | §1 |
| TST | min | True Solar Time | §1 |
| TZ | h | Zeitzonen-Offset gegen UTC (signed) | §1 |
| T_a | °C | Umgebungstemperatur | §8 |
| T_c | °C | PV-Modul-Zelltemperatur | §8 |
| η_temp | — | Temperatur-Wirkungsgrad-Faktor (≤ 1) | §8 |
| f_view | — | Diffuser View-Factor des Moduls | §8 |
| d | %/Jahr | Modul-Degradation pro Jahr — feste Modell-Konstante 0,3 %/Jahr, kein User-Input | §7 |
| r | — | Degradations-Faktor 1 − d/100 | §7 |
| S | €/Jahr | Year-1-Ersparnis | §7 |
| N | Jahre | Amortisationsdauer | §7 |
| DNI / DHI / GHI | W/m² | Direct-Normal / Diffuse-Horizontal / Global-Horizontal Irradiance | §10 |
| COP / SCOP | — | Coefficient / Seasonal Coefficient of Performance (Wärmepumpe) | §4 |
| P_i | kWp | DC-Peak-Power von Array i (Multi-Array-Setup) | §11 |
Formel-Boxen. Dreifach-Backtick-Code-Blöcke ohne Sprach-Tag. Nummerierung (N.k) am Zeilenanfang erlaubt Verweise im Fließtext (z.B. „Gleichung (3.4) verwirft …”). Operatoren in Formeln: · (Multiplikation), − (Minus, U+2212), Δ (Differenz/Schrittweite), Σ (Summe).
0.5. Länder-Defaults
Was steht hier?
Zentral nachschlagbare Standard-Werte pro Land, die in den Sektionen 3, 4, 7, 8 als Inputs erscheinen. Single Source of Truth ist country_defaults.json — die App lädt diese Werte beim Start und stellt sie über CountryDefaultsRepository bereit.
Werte (Stand 2026-07). Preise, Einspeisetarife und CO₂-Faktoren im Juli 2026 gegen Regulatoren/Netzbetreiber/Statistikämter nachrecherchiert (siehe Quellen unten); Balkon-Limits auf die papierlose Plug-&-Play-Schwelle des jeweiligen Landes korrigiert. Balkon-Standard = panelCount × wattPerPanel Wp / inverterLimitW, in Klammern der DC-Modul-Cap peakLimitW, falls dokumentiert.
| Code | Land | Währung | Strompreis | Einspeisetarif | CO₂-Faktor | f_diff | Balkon-Standard | Haushalts-kWh (single / couple / familySmall / familyLarge) |
|---|---|---|---|---|---|---|---|---|
| DE | Deutschland | EUR | 0,35 €/kWh | 0,078 €/kWh | 0,34 kg/kWh | 0,50 | 2×430 Wp / 800 W (Cap 2000 Wp) | 1500 / 2500 / 3500 / 4500 |
| AT | Österreich | EUR | 0,28 €/kWh | 0,07 €/kWh | 0,16 kg/kWh | 0,48 | 2×430 Wp / 800 W (Cap 2000 Wp) | 1700 / 2800 / 3900 / 5000 |
| CH | Schweiz | CHF | 0,28 CHF/kWh | 0,08 CHF/kWh | 0,03 kg/kWh | 0,46 | 2×600 Wp / 600 W | 1900 / 3100 / 4300 / 5500 |
| NL | Nederland | EUR | 0,30 €/kWh | 0,06 €/kWh | 0,33 kg/kWh | 0,55 | 2×430 Wp / 800 W | 1500 / 2400 / 3300 / 4200 |
| BE | België | EUR | 0,35 €/kWh | 0,04 €/kWh | 0,13 kg/kWh | 0,55 | 2×430 Wp / 800 W | 1600 / 2700 / 3700 / 4700 |
| FR | France | EUR | 0,20 €/kWh | 0,04 €/kWh | 0,05 kg/kWh | 0,48 | 2×430 Wp / 800 W | 2200 / 3500 / 4900 / 6300 |
| IT | Italia | EUR | 0,30 €/kWh | 0,10 €/kWh | 0,26 kg/kWh | 0,40 | 1×430 Wp / 350 W | 1300 / 2100 / 2900 / 3700 |
| ES | España | EUR | 0,22 €/kWh | 0,06 €/kWh | 0,17 kg/kWh | 0,35 | 2×430 Wp / 800 W | 1500 / 2400 / 3300 / 4200 |
| PL | Polska | PLN | 1,10 zł/kWh | 0,22 zł/kWh | 0,62 kg/kWh | 0,50 | 2×400 Wp / 800 W (Cap 800 Wp) | 1200 / 2000 / 2800 / 3600 |
| CZ | Česko | CZK | 6,50 Kč/kWh | 1,50 Kč/kWh | 0,40 kg/kWh | 0,50 | 2×430 Wp / 800 W | 1500 / 2400 / 3300 / 4200 |
| GB | United Kingdom | GBP | 0,26 £/kWh | 0,12 £/kWh | 0,12 kg/kWh | 0,55 | 2×430 Wp / 800 W (Cap 2000 Wp) | 1800 / 2700 / 3600 / 4500 |
Quellen pro Spalte.
- Strompreis (Haushalts-Endverbraucher, inkl. Steuern und Netzentgelte): Eurostat „Electricity prices for household consumers” (Datensatz
nrg_pc_204), letztes verfügbares Semester, Verbrauchsklasse DC (2.500–5.000 kWh/Jahr). Wird in (7.1)yearlySavings = selfUsed · electricityPrice + fedIn · feedInTariffverwendet. - Einspeisetarif: ZWEI Werte pro Land in
country_defaults.json. (1)feedInTariffPerKwh= Aufdach-Tarif für Anlagen ≤10 kWp (EEG/Obligation d’Achat/lokales Marktpreis-Modell). (2)feedInTariffBalconyPerKwh= De-facto-Vergütung für Balkonkraftwerke 2026. Letztere ist in DE/AT/BE/FR/IT/CZ/GB = 0,00, weil der bürokratische Aufwand (bidirektionaler Zähler, Anmeldung, Vertragsabschluss) für die paar Euro/Jahr prohibitiv ist (GB: SEG-Export braucht ein MCS-Zertifikat, das Plug-in-Kits fehlt) und die meisten Balkon-Besitzer den Tarif schlicht nicht abrufen. NL hat Salderingsregeling-Abbau (0,05 Teil-Tarif; Voll-Saldierung endet abrupt 2027-01-01, danach nur ~50 % des Bezugstarifs), CH stark kantonal (~0,06, deckt sich mit der neuen 6-Rp-Mindestvergütung 2026). PL nutzt dynamisches Marktmodell, deshalb identisch mit Aufdach. Aufdach-Tarife 2026 aktualisiert (Nachrecherche 2026-07): die Einspeisung ist EU-weit stark gefallen — DE 0,078 (EEG ab 02/2026), CH 0,08 (ElCom-Markt + 6-Rp-Mindest), NL 0,06 (Terugleververgoeding nach Saldierungs-Abbau), BE 0,04 (Injectietarief Vlaanderen), FR 0,04 (Arrêté S21 06/2026 zahlt Neu-Anlagen real nur 0,011), ES 0,06 (compensación de excedentes). Quellen: Bundesnetzagentur, OeMAG/E-Control, ElCom, business.gov.nl/Rijksoverheid, VREG/Synergrid, photovoltaique.info, Ofgem, pv-magazine.com (Stand 2026-07). Der Wizard seedet je nachplantType(balcony/rooftop) aus dem passenden Feld; bei Balkon-Default-0 zeigt der Economics-Step einen warmtonigen Inline-Hinweis, dass der User den Wert überschreiben kann, wenn er entgegen der Realität einen Einspeisevertrag hat. - CO₂-Faktor: Umweltbundesamt (DE), IEA/AIB/Nowtricity/electricityMaps „Grid Carbon Intensity” (andere Länder), Strommix-Durchschnitt, aktuellstes Jahr. Fließt in (7.3)
co2SavingsKgPerYear = production · co2Factorein. 2026-07 nachgezogen (mehrere Werte waren veraltet und überschätzten die CO₂-Ersparnis — Anti-Hype-Bruch): DE 0,38→0,34 (UBA 2025: 344 g), BE 0,20→0,13 (AIB 2024/25: ~132 g), PL 0,72→0,62 (Nowtricity 2025: 618 g, fallend), CZ 0,45→0,40 (Nowtricity 2025: 382 g, kernkraftlastig). GB neu 0,12 (2024-Schnitt 124 g, Kohle-Ausstieg 09/2024). FR 0,05 (RTE ~41 g, Atomstrom) und CH 0,03 unverändert. - f_diff (Diffus-Fraktion): PVGIS SARAH-2 Klimatologie 2005–2020, gemittelt über die Haupt-Bevölkerungsschwerpunkte. Bedeutung: Anteil der Globalstrahlung, der diffus (DHI) statt direkt (DNI) am Boden ankommt. Mediterrane Klimate haben weniger Bewölkung → niedrigerer f_diff; atlantische Küsten haben mehr Bewölkung → höherer f_diff. Fließt in (3.1) ein.
- Balkon-Standard: gesetzliche oder marktübliche papierlose Plug-and-Play-Konfiguration (
panelCount × wattPerPanel / inverterLimitW, optionalpeakLimitWals DC-Modul-Cap). AC-Limit meist 800 W (DE/AT seit 2024, davor 600 W; GB ab 2026-04-15 via BS 7671 Amendment 4; restliche EU pragmatisch 800 W aus der EU-Niederspannungs-Richtlinie), CH 600 W (Hardware, nicht drosselbar). IT-Sonderfall (2026-07 korrigiert): nur 350 W AC sind ohne Behördenpapiere zulässig — die papierfreie Plug-&-Play-Klasse nach CEI 0-21 / e-distribuzione endet bei 350 W; 350–800 W brauchen Konformitätserklärung, Schaltschema und Regolamento d’esercizio. Der frühere IT-Default 2×430 Wp / 800 W überschätzte, was ein Nutzer papierlos installieren darf → jetzt 1×430 Wp / 350 W (Anti-Hype-Korrektur). balconyDefault.peakLimitW(optional, Wp): Cap auf die Total-DC-Modulleistung der Balkonsolar-Konfiguration. Gesetzt für DE/AT: 2000 Wp (§8b EEG, in Kraft seit Mai 2026: bis zu 2,0 kWp DC bei 800 W AC ohne Netzbetreiber-Anmeldung; AT symmetrisch), GB: 2000 Wp (BS 7671 Amendment 4: 800 W AC-Wechselrichter, DC-Module bis 2000 W) und PL: 800 Wp (Sonderfall — PL bemisst die papierlose Balkon-Schwelle DC-seitig an der Modul-Gesamtleistung, nicht am Wechselrichter; Default daher 2×400 = 800 Wp). Andere Länder bleibennull= kein dokumentierter DC-Cap → User skaliert frei innerhalb der jeweiligen AC-Inverter-Vorgabe. Wirkung in der App: UI-Sperre des Stepper-Plus und „Array hinzufügen”-Buttons im Panel-Screen, plus dezenter Inline-Hinweis mit Quellenangabe. Wechselt der User zu Aufdach (Premium), entfällt der Cap. Wirkt nur auf die UI, nicht aufs Modell — wenn die Konfiguration jemals über dem Cap landet (Pre-Cap-Slot, Land-Wechsel, Premium → Free), bleibt sie unverändert (siehe §14 „Was wird nicht automatisch invalidiert”).- Haushalts-kWh: vereinfachte Eurostat-2024-Residential-Energy-Werte, auf 100er gerundet. Verhältnis single : couple : familySmall : familyLarge ≈ 1 : 1,65 : 2,3 : 3 (angelehnt an BDEW-H25 DE). Klimazonen-Effekte sind hier nicht modelliert — der Wert ist eine grobe Tagesform-Skalierung in §4, kein präziser Verbrauchsschätzer für den individuellen Haushalt.
Nicht-modellierte Felder. priceIncreasePerYearPercent (DE 3 %, PL 4 %, CH 2 %, sonst 3 %) ist in country_defaults.json hinterlegt, fließt aber nicht in die Wirtschaftlichkeitsrechnung ein. Begründung in §7: Year-1-Ersparnis ist bewusst konservativ ohne Inflations-Projektion. Wenn Strompreis-Inflation einmal modelliert wird (Premium-Hook), wird dieses Feld zur Quelle.
Re-Kalibrierung.
- Eurostat publiziert halbjährlich → Strompreis-Werte ein- bis zweimal pro Jahr nachziehen.
- IEA-Major-Release (üblich Q1) → CO₂-Faktoren aktualisieren.
- GB (2026-07 ergänzt): mit dem englischen UI-Sprachraum kam United Kingdom als 11. Land dazu (Befund 5 der Geräte-Verifikation 2026-07-07). Damit hat jede der 8 App-Sprachen ein passendes Default-Land. Balkon-PV in GB seit BS 7671 Amendment 4 (in Kraft 2026-04-15) bis 800 W AC / 2000 Wp DC legal.
- Datenlücke (bewusst offen): IE, US und weitere englischsprachige Märkte haben kein eigenes Default-Land — ein UK-/US-/IE-Standort ohne DE-Fallback-Kollision landet auf GB (nächstliegend atlantisch) bzw. bei US auf dem DE-Fallback. US-Balkon-PV ist regulatorisch noch nascent (kein bundesweites Plug-in-Regime); IE ist klein. Beide zurückgestellt, bis Nachfrage/Datenlage es rechtfertigt.
- Bei neuem Land-Eintrag (FI/NO/SE/GR/PT/IE im Backlog §13): Eintrag in
country_defaults.jsonergänzen, diese Tabelle erweitern, Diffus-Fraktion aus PVGIS-SARAH-2 ableiten. - Wenn ein Land nicht in der Tabelle steht, fällt die App in
country_defaults_repository.dartauf DE zurück (Default) — das ist eine konservative Annahme für Mitteleuropa, aber kann in Süd- oder Nordeuropa um ± 10 % daneben liegen.
1. Sonnenstand (Solar Position)
Was rechnet die App? Für jeden Zeitpunkt im Jahr und jeden Standort: Wo steht die Sonne (Elevation α und Azimut γ)? Diese Position ist die Grundlage für alles, was mit Verschattung, Sun-Path-Hülle und PV-Tagesform zu tun hat.
Eingaben. Lokale Uhrzeit (mit Datum), Latitude φ, Longitude, Zeitzonen-Offset TZ gegen UTC.
Modell & Formel.
Die App verwendet den NOAA Solar Position Algorithm in seiner Fourier-vereinfachten Form. Alle Reihen-Koeffizienten stehen 1:1 in solar_math_service.dart.
(1.1) Fractional Year (Jahres-Position als Winkel in Radiant)
Γ = (2π / 365) · (N − 1 + (h − 12) / 24)
mit N = Day-of-Year (1..366), h = Stunde der lokalen Zeit.
(1.2) Equation of Time (Zeitgleichung, in Minuten) — 5-Term-Fourier
EqTime = 229.18 · ( 0.000075
+ 0.001868 · cos(Γ)
− 0.032077 · sin(Γ)
− 0.014615 · cos(2Γ)
− 0.040849 · sin(2Γ) )
(1.3) Sonnen-Deklination δ (in Radiant) — 7-Term-Fourier
δ = 0.006918
− 0.399912 · cos(Γ)
+ 0.070257 · sin(Γ)
− 0.006758 · cos(2Γ)
+ 0.000907 · sin(2Γ)
− 0.002697 · cos(3Γ)
+ 0.001480 · sin(3Γ)
(1.4) True Solar Time (Minuten, lokal)
timeOffset = EqTime + 4 · longitude − 60 · TZ
TST = h·60 + m + s/60 + timeOffset
(1.5) Stundenwinkel H (in Grad)
H = TST / 4 − 180
(1.6) Zenitwinkel z über cos
cos(z) = sin(φ) · sin(δ) + cos(φ) · cos(δ) · cos(H)
α = 90° − z (Elevation)
(1.7) Azimut γ (in Grad, 0=Nord, 90=Ost, 180=Süd, 270=West)
cos(γ_raw) = ( sin(φ)·cos(z) − sin(δ) )
/ ( cos(φ)·sin(z) )
γ = (γ_raw + 180) mod 360 falls H > 0 (nach Solar Noon)
γ = (540 − γ_raw) mod 360 falls H ≤ 0 (vor Solar Noon)
γ_raw = acos(…) aus dem auf [−1, 1] geclampten Argument; die Fall-Unterscheidung über H löst das Vorzeichen der Azimut-Rotation auf. Wenn der Nenner in (1.7) numerisch verschwindet (Sonne im Zenit), wird das Argument auf 1.0 gesetzt — das passiert nur in den Tropen und ist UI-irrelevant.
Annahmen & Vereinfachungen.
- Atmosphärische Refraktion nicht modelliert. Volle NREL-SPA-Implementierung würde ~0,5° Bias am Horizont korrigieren (Sonne erscheint höher als geometrisch berechnet). Für Sonnenauf/-untergang einer Astronomie-Anwendung relevant — für Verschattungs- und PV-Analysen vernachlässigbar, weil flache Sonnenstände energetisch klein sind.
- Sphärische Erde (keine Ellipsoid-Korrektur). Bias < 0,01° in den hier verwendeten Größen.
- Aberration / Lichtgeschwindigkeit nicht modelliert. Bias < 0,005°.
- Schaltjahre. Day-of-Year wird gegen
DateTime(year, 1, 1)berechnet — Schaltjahre werden also korrekt durchgezählt, aber die Fourier-Reihen sind auf 365 Tage parametrisiert. Bias an Solstice-Tagen ~0,25°.
Wahl der Werte (Modell-Reduktion statt vollem NREL-SPA).
- NREL-SPA (Reda & Andreas 2003) wäre die präziseste Alternative mit < 0,0003° Genauigkeit und ~40 Termen in periodischen Reihen pro Komponente. Verworfen, weil die Kosten (Code-Größe, CPU pro Sample) sich für unseren Anwendungsfall nicht lohnen — Verschattung und Sun-Path brauchen keine sub-Bogenminuten-Präzision.
- NOAA Fourier-Form ist auf dem NOAA Earth System Research Lab Solar Position Calculator (
gml.noaa.gov/grad/solcalc/calcdetails.html) dokumentiert. Die Koeffizienten (0.000075, 0.001868, …, 0.001480) sind aus dem NOAA-Standard-Datensatz übernommen, nicht neu kalibriert. - Alternative „grobes Modell” (Cooper 1969 für δ + lineare EqTime) wäre noch schneller, hätte aber ~3° Bias im Frühjahr/Herbst — das macht den Sun-Path-Korridor in unserer App-UI sichtbar falsch (User sähe den Sonnenkorridor um Wochen verschoben).
TZ-Konvention für Sun-Path-Berechnung (seit 2026-05-25).
Alle Sun-Path-Konsumenten (SolarMathService.dailyArc, PvHourlyEstimator.monthlyHourlyWatts, ShadingService.estimateAnnualLossPercent, SunPathEnvelopeService.compute, der Polar-Chart im PDF-Export) bekommen den Zeitzonen-Offset als Solar-Zeit-Approximation:
tzOffsetHours = longitude / 15.0
(15° Erdrotation pro Stunde → der Längengrad ist die natürliche Zeitzonen-Quelle für die Sonnenbahn.)
Nicht verwendet wird:
DateTime.now().timeZoneOffset(Geräte-TZ) — wäre falsch, sobald der User einen Standort plant, der nicht seine eigene Zeitzone ist. Beispiel-Bug-Fall vor dem Fix: Berlin-User auf Beijing-Standort sah den Sommer-Peak im Tageschart bei ~06:00 statt ~12:00 (6h Phase-Verschiebung). Vorgängerversion war zudem inkonsistent — zwei der fünf Aufrufer nutzten.inHours.toDouble()(ganzzahlige Trunkierung) statt.inMinutes / 60.0. Ausnahme siehe unten: Ist-Zeit-Sonnenposition.- IANA-Lookup via
package:timezone— ~500 KB Bundle-Overhead für eine politische Information, die für die Sun-Path-Mathematik nicht gebraucht wird.
Begründung. Die Sonne folgt dem Längengrad, nicht der politischen Uhr. Eine PV-Ertragsrechnung muss den geographischen Mittag (Sonnen-Mittag) als 12:00-Anker nutzen, sonst wandert der Tagespeak weg von der physikalischen Realität.
Genauigkeit. ±1h gegen die lokale Wand-Uhr in Ländern mit politisch geschnittenen Zonen (China-Standardzeit über fünf geographische Zonen, Indien-Halbzonen) oder bei aktivem DST. Der Bias betrifft nur die optische Lage des Peak-Labels im UI-Tageschart und die Phase der Lastüberlagerung im stündlichen Eigenverbrauchs-Modell — nicht die Jahres-/Monats-kWh-Berechnung (die kommt aus PVGIS/NLR/NASA und ist Tages-summiert).
Ausnahme: Ist-Zeit-Sonnenposition (seit 2026-07-09). Überall dort, wo die App die Sonnenposition zum jetzigen Moment berechnet und gegen die echte Sonne am Himmel hält — Live-Sonnen-Marker im Verschattungs-Sucher und Sun-Genauigkeits-Check (live_capture_view.dart _updateSunPosition, shading_screen.dart Sun-über-Horizont-Gate) — gilt die Solarzeit-Näherung nicht. positionAt erwartet die Wandzeit zur übergebenen Zeitzone; DateTime.now() ist aber Wandzeit der politischen Geräte-TZ. Die Kombination DateTime.now() + lon/15 erzeugte einen systematischen Fehler von TZ − lon/15 (DACH im Sommer ≈ 70–90 min) — die berechnete Sonne stand mittags 25–40° zu weit westlich, der Sun-Check meldete konstant „>30° Abweichung” (Befund Gerätetest 2026-07-09, nachgerechnet für Köln 12:00 MESZ: ΔAz +40°). Ist-Zeit-Aufrufer übergeben daher now.timeZoneOffset.inMinutes / 60.0. Das widerspricht dem Beijing/Berlin-Argument oben nicht: die Ausnahme gilt genau dann, wenn der Nutzer mit der Kamera physisch am analysierten Standort steht (Voraussetzung des Live-Anvisierens) — dann ist Geräte-TZ = Standort-TZ per Konstruktion. Ganztages-/Jahres-Integrale sind von der Wahl unberührt (die Positionsmenge eines Tages ist unabhängig von der Zeit-Etikettierung). Regression-Test: solar_math_service_test.dart Gruppe „TZ-Konvention für Ist-Zeit-Sonnenposition”.
Quelle. NOAA Solar Position Calculator nutzt dieselbe Solar-Zeit-Konvention für die interne Sun-Path-Mathematik. Der bewusst gewählte Quick-Fix ist Anti-Hype-konform — die ±1h-Drift ist im Methodik-Screen explizit zu nennen, sobald dieser kommt.
Code. yield_provider.dart:152, shading_screen.dart:532+569, polar_sun_path_overview.dart:84, pdf_export_service.dart:612. Ist-Zeit-Ausnahme: live_capture_view.dart _updateSunPosition, shading_screen.dart initState.
Genauigkeit.
± 1° für Verschattungs- und Sun-Path-Anwendungen. Vergleich gegen US Naval Observatory Almanac für ein volles Jahr in 10-Min-Schritten an typischen DACH+EU-Standorten zeigt maximale Abweichung 0,8° für Elevation und 1,1° für Azimut — jeweils bei flacher Sonne unter 5° Elevation, wo das Modell systematisch schwächer wird.
Wo darf man nicht trauen?
- Solar-Tracker-Steuerung (braucht < 0,05° für effiziente Nachführung).
- Präzise Sonnenfinsternis-Vorhersage.
- Sub-Bogenminuten-Messungen für astronomische Anwendungen.
- Polare Standorte mit Polartag/-nacht — das Modell rechnet weiter, aber die Elevation springt zwischen positiv und negativ, sobald die Sonne den Horizont knapp streift.
Quelle.
NOAA Earth System Research Lab Solar Position Calculator — Algorithmus-Reduktion in Fourier-Form. Originalreferenz im Code-Doc-Comment.
Code: solar_math_service.dart.
Re-Kalibrierung.
Stabil. Nur anrühren, wenn ein Bedarf für höhere Präzision (Tracker, Astronomie) entsteht — dann auf vollständigen NREL SPA (Reda & Andreas 2003) wechseln, oder als alternativen Pfad SolarMathService.positionAtPrecise() parallel ergänzen. Die aktuelle 1°-Genauigkeit ist für die App-Verschattungs-UX und die PV-Tagesform-Schätzung in §8 kalibriert.
2. Sonnenbahn-Hülle (Sun-Path-Envelope)
Was rechnet die App? Pro Azimut-Bin: niedrigste und höchste Elevation, in der die Sonne dort übers Jahr vorbeikommt. Dient als UI-Overlay im Live-Capture und Marker-Step — zeigt dem User, in welchem Korridor Verschattungsobjekte überhaupt zählen.
Eingaben. Standort (φ, longitude), Zeitzone TZ, Azimut-Bin-Breite Δγ (Default 2°), Tagesschritt Δd (Default 3 Tage), Referenzjahr (Default 2025).
Modell & Formel.
(2.1) Jahres-Sampling
D = { 1. Januar + k·Δd | k = 0, 1, …, ⌊365/Δd⌋ }
Für jeden Tag d ∈ D wird über SolarMathService.dailyArc(d, …)
ein 10-Min-Raster aller Sonnenpositionen mit α > 0 erzeugt.
(2.2) Bin-Rounding
bin(γ) = ( round(γ / Δγ) · Δγ ) mod 360°
Beispiel: Δγ = 2°, γ = 137,3° → bin = 138°.
(2.3) Aggregation pro Bin (über alle Tage und 10-Min-Samples)
α_min[bin] = min { α | (γ, α) trifft bin }
α_max[bin] = max { α | (γ, α) trifft bin }
(2.4) Lineare Interpolation zwischen Bins (für Polar-Plot-Glättung)
Wenn γ in [bin_a, bin_b]:
t = (γ − bin_a) / (bin_b − bin_a)
f(γ) = f[bin_a] + ( f[bin_b] − f[bin_a] ) · t
Außerhalb [min(keys), max(keys)] gibt `minElevationAt` `null`
zurück — die Sonne kommt dort nie vorbei.
Pro Standort entstehen mit den Defaults ca. (365 / 3) · (24·60 / 10) · 0,7 ≈ 12.300 Sonnenpositions-Samples (Faktor 0,7 für Stunden mit α > 0). Das reicht, damit jeder 2°-Azimut-Bin viele Treffer bekommt und Min/Max nicht zwischen einzelnen Tagessamples springen.
Annahmen & Vereinfachungen.
- 3-Tage-Sampling reicht, weil Sommer- und Wintersonnenwende auf ±1 Tag genau getroffen werden — das ist die genauigkeitsbestimmende Größe für
α_maxundα_min. BeiΔd = 1würde der Korridor maximal um 0,03° schmaler, beiΔd = 7sichtbare Artefakte (Solstice-Plateau wird verfehlt). - 2°-Azimut-Bins sind feiner als die UI-Auflösung des Polar-Plots — keine sichtbaren Sprünge.
- Schaltjahre werden nicht gesondert behandelt (Vernachlässigung: ~0,25° Sonnenstand, siehe §1).
- Außerhalb des Sonnenkorridors liefert
minElevationAtnull(nicht 0). Das ist wichtig für UI und Verschattungs-Math: einnull-Bin bedeutet „keine Sonne”, nicht „Sonne knapp über Horizont”.
Wahl der Werte (Sampling-Granularität).
- Δd = 3 Tage ist die Default-Balance: Trifft Solstice-Tag auf ±1 Tag (entsprechend ~0,03° in α_max nahe Mittsommer), reicht für die UX-Glättung des Polar-Plots, kostet einmalig 200 ms CPU pro neuem Standort.
- Δd = 1 Tag kostet ~3× CPU-Zeit (≈ 600 ms), Genauigkeitsgewinn vernachlässigbar.
- Δd = 7 Tage würde 0,1°-Sprünge in α_max nahe Solstice produzieren — sichtbar im Polar-Plot, weshalb wir nicht so weit ausgedünnt haben.
- Δγ = 2° ist die zweite kalibrierte Größe. Bei Δγ = 5° wären die Bin-Sprünge sichtbar gewesen; bei Δγ = 1° verdoppelt sich die Bin-Zahl ohne sichtbaren Gewinn.
Genauigkeit.
Hülle exakt im Rahmen des Sonnenstand-Modells (§1, ± 1°). Für die Verschattungs-UX gut: User sieht den Korridor in 2°-Bins, kann seine fotografierten Horizont-Linien dagegen abgleichen. Für stundengenaue DNI-Profile zu grob — das ist auch nicht der Zweck der Envelope.
Wo darf man nicht trauen?
- Sub-Tages-Auflösung — die Envelope mittelt über das Jahr, nicht über einen konkreten Tag. Wer einzelne kritische Sonnenpositionen bewerten will (z.B. „21. Juni 10:00 Uhr”), nutzt
SolarMathService.dailyArc()direkt. - Polartag/-nacht: das Modell rechnet, aber der Korridor entartet (Min und Max liegen sehr nah beieinander, oder ganzer Azimut-Bereich fehlt).
Quelle.
Code: sun_path_envelope_service.dart. Nutzt solar_math_service.dart (§1).
Re-Kalibrierung.
Wenn der Tagesschritt von 3 Tagen sichtbare Artefakte im Polar-Plot zeigt → auf 1 Tag senken (kostet ~3× CPU-Zeit, einmalig pro Standort und gecached). Wenn die UI-Auflösung mal feiner wird (z.B. 1°-Polar-Plot), Δγ entsprechend nachziehen.
3. Verschattungs-Verlust (direkt + diffus)
Was rechnet die App? Schätzt den jährlichen Strahlungs-Verlust durch eine Horizont-Maske (Verschattungsobjekte aus Foto-Capture), gewichtet danach, was das Modul aus seiner Ausrichtung heraus überhaupt sehen kann.
Eingaben. Konsolidierte Shading-Mask (Map γ → Horizont-Elevation), Standort (φ, longitude), Zeitzone TZ, Panel-Azimut γ_p und -Tilt β (pro Array), Diffus-Fraktion f_diff (siehe §0.5).
Modell & Formel.
(3.1) Hauptformel — kombinierter Verschattungsverlust [%]
L = (1 − f_diff) · L_direct + f_diff · L_diffuse
(3.2) Angle of Incidence (PV-Standardprojektion)
cos(AoI) = sin(α) · cos(β) + cos(α) · sin(β) · cos(γ − γ_p)
Negative Werte = Sonne im rückwärtigen Halbraum des Moduls
→ kein Beitrag zur Direkt-Strahlung.
(3.3) Sample-Gewichtung im Direkt-Verlust
w(α, γ) = sin(α) · max(0, cos(AoI))
sin(α) ist die Projektion auf die horizontale Globalstrahlungs-
Fläche (W/m² → kWh proportional zu sin α); cos(AoI) ist die
Projektion auf die geneigte Modulfläche.
(3.4) Direkt-Verlust durch Maske
L_direct = ( Σ_blocked w(α, γ) / Σ_total w(α, γ) ) · 100
mit Σ_total = Summe über alle Sun-Path-Samples (12 Repräsentativ-
tage × 10-Min-Raster) mit cos(AoI) > 0, Σ_blocked = Untermenge
mit α ≤ horizon(γ) der Maske.
(3.5) Sky-View-Factor (isotrop, hemisphärisch gemittelt)
SVF = (1/N) · Σ_{i=0..N-1} ( 1 − sin( horizon(γ_i) ) )
mit γ_i = i · Δγ_SVF, Δγ_SVF = 5°, N = 72.
(3.6) Diffus-Verlust
L_diffuse = ( 1 − SVF ) · 100
Orientierungsunabhängig — das Modul „sieht" stets eine Halb-
sphäre, die zwar mit β die Form ändert, aber dieselbe Anzahl
Steradiant abdeckt.
Die Sun-Path-Samples in (3.4) kommen aus 12 Repräsentativtagen (jeweils der 15. eines Monats) × SolarMathService.dailyArc() im 10-Min-Raster. Sonnenstände mit cos(AoI) ≤ 0 werden bereits in (3.3) entfernt — sie zählen weder in Σ_total noch in Σ_blocked. Dadurch werden Ost/West/Nord-Aufbauten nicht mehr für den rückwärtigen Halbraum bestraft (Sprint-K-Fix, siehe Re-Kalibrierung).
Die SVF-Mittelung in (3.5) ist die diskrete Form des Integrals (1/2π) · ∫₀^{2π} (1 − sin(h(γ))) dγ, mit dem analytischen Anteil pro Azimut-Streifen ∫_{h(γ)}^{π/2} cos(α) dα = 1 − sin(h(γ)). Δγ_SVF = 5° (72 Bins) ist gröber als die Direkt-Sampling-Δγ = 2° — bewusst, weil der SVF eine Mittel-Größe ist und feinere Sampling kein zusätzliches Signal liefert.
Multi-Array-Aggregation.
Wenn das Setup mehrere Panel-Arrays mit unterschiedlicher Ausrichtung hat (z.B. Ost + West auf demselben Balkon), wird (3.1)–(3.6) pro Array mit dessen γ_p, β und array-spezifischer Maske gerechnet. Die globale Aggregation für die Anzeige passiert in §11:
(3.7) Globaler Verschattungsverlust (gewichteter Durchschnitt nach DC-Peak)
L_global = ( Σ_i L_i · P_i ) / ( Σ_i P_i )
mit L_i = Array-Verlust aus (3.1), P_i = DC-Peak-Power [kWp] von Array i.
Die pro-Array-Maske entsteht via ShadingMask.fromCapturesForArray(captures, arrayAzimuthDeg, sectorHalfDeg=90) — nur Captures, deren Kamera-Azimut im Sicht-Sektor [γ_p ± 90°] des Arrays liegen, fließen ein. Bei leerem Filter (keine Captures im Sektor) gilt L_i = 0 für dieses Array.
Annahmen & Vereinfachungen.
- Isotroper Diffushimmel. Das Modell behandelt die Diffusstrahlung als gleichmäßig über die Himmelshalbsphäre verteilt — eine Hay-Davies-artige Vereinfachung. Volles Perez-Modell bräuchte stündliche DNI/DHI-Werte und ein anisotropes Himmelsmodell (Circumsolar-Anteil + Horizon-Brightening).
- 12 Repräsentativtage (15. jedes Monats) für Direkt-Sampling — ausreichend, weil Monatsvariation glatter ist als Tag-zu-Tag-Wettervariation. Mehr Sample-Tage bringen vernachlässigbaren Genauigkeitsgewinn.
- 10-Min-Raster ist fein genug, dass schmale Hindernisse (Schornsteine, Antennen) nicht zwischen zwei Samples hindurchfallen.
- Maskenrand-Verhalten. Außerhalb der gemessenen Azimut-Range der Maske wird kein Schatten angenommen (Horizont = 0), nicht der letzte gemessene Wert fortgeschrieben. Begründung: sonst würde eine 30°-Hauswand bei γ = 258° fälschlich bis zum Sonnenuntergang bei γ = 310° extrapoliert.
Wahl der Werte (f_diff und Sensitivität).
- f_diff landesabhängig aus §0.5. DE/AT/CH 0,50, NL/BE 0,55, FR 0,48, IT 0,40, ES 0,35, PL/CZ 0,50. Quelle: PVGIS SARAH-2 Klimatologie 2005–2020.
- Alternative: Hay-Davies oder Perez pro Stunde mit echten DNI/DHI-Werten. Verworfen, weil das stündliche Strahlungsinputs erfordert, die unser Solar-Provider (§10) nur als Monatsmittel liefert. Der Genauigkeitsgewinn würde durch Input-Granularität wieder verloren.
- Sensitivität. Ein Fehler von 0,10 in f_diff verschiebt L bei typischer Mask-Konfiguration (SVF ≈ 0,8, L_direct ≈ 20 %) um etwa 2 Pp. Heißt: wenn der App-User in Spanien wäre (f_diff ≈ 0,35), wir aber DE-Default (0,50) annähmen, läge L systematisch ~3 Pp zu hoch.
- Δγ_SVF = 5°. Mit 1°-Bins würde der SVF-Wert unter 0,01 variieren — nicht der Aufwand wert.
Genauigkeit.
± 2–3 Prozentpunkte Verlust gegenüber stundengenauen Strahlungs-Solvern, solange f_diff halbwegs zur Region passt. Bei Wahl der falschen Diffus-Fraktion (z.B. DACH-Default in Spanien) kommen 5–10 Prozentpunkte zusätzliche Abweichung dazu — deshalb die Lokalisierung in §0.5.
Wo darf man nicht trauen? Hochalpine Standorte mit stark anisotropem Himmel (klare Wüste, hohe Albedo durch Schnee). Standorte mit Reflexion durch helle Nachbarwände (Albedo-Rückgewinn nicht modelliert).
Quelle.
Code: shading_service.dart, shading_mask.dart. Klimatologie: PVGIS SARAH-2-Atlas, JRC.
Re-Kalibrierung.
f_diffpro Land aktualisieren bei wesentlichen Klimadaten-Updates (PVGIS-Major-Release).- Bei Aufnahme weiterer Länder (FI, NO, SE, GR, PT, IE) Diffus-Fraktion ergänzen.
- 2026-05-15 (Sprint K): Direkt-Verlust ist seitdem panel-orientierungsspezifisch (
max(0, cos(AoI))als zusätzliche Sample-Gewichtung). Vor dem Fix wurde nur mitsin(elev)gewichtet, sodass der rückwärtige Halbraum des Moduls fälschlich mitgezählt wurde — Ost/West/Nord-Aufbauten waren systematisch zu pessimistisch. Diffus-Verlust bleibt unverändert orientierungsunabhängig. DasPolarSunPathOverviewzeigt den rückwärtigen Halbraum seit demselben Sprint gedimmt, damit die Logik visuell mit dem Modell übereinstimmt. - 2026-05-19 (Sprint K, K3-K6): Capture-Aufnahme von „3 Fotos pro Array” auf einen globalen Multi-Array-Plan umgestellt. Der
CapturePlannerService.planMultiArraypflastert die Vereinigung der Sicht-Halbsphären[A ± 90°]aller Arrays lückenlos mitstepMax = fovDeg − marginDeg ≈ 42°Schrittweite und macht jeden Panel-Mittelpunkt zum Pflicht-Anker. Damit verschwindet die Redundanz (4 Arrays → ≤ 12 statt 12 nahezu identischer Aufnahmen) und der Polar-Plot zeigt pro Array eine eigene farbcodierte Linie statt einer falschen globalen Aggregation. Das Schema inHoriSolConfigist V2: nur noch eine flacheshadingCaptures-Liste; V1-Slots (mitadditionalShadingCaptures/additionalShadingMasks) werden beim Laden komplett verworfen — kein Migrationspfad, weil die App noch nicht released ist. - 2026-05-20 (Sprint L, F3): Captures sind standortspezifisch — bei einem Standort-Wechsel > 50 km zeigt der LocationScreen im editMode einen Warning-Banner und bietet selektives Verwerfen an. Schwelle 50 km: Berlin-Durchmesser ~40 km löst keinen Banner aus; Berlin–Leipzig ~190 km tut es. Der Sun-Path-Envelope ändert sich nennenswert ab ~1° Breitengrad-Δ ≈ 111 km — 50 km ist konservativ und deckt auch Grenzregionen ab.
HoriSolConfig.locationAtCapture: UserLocation?wird parallel zupanelArrayAzimuthsAtCapturebeim_continue()im ShadingScreen gesetzt. UX-Flow:docu/ux-flow.md §1.10.
3.1 Geometrische Grenzen: Punkt-Messung auf ausgedehnte Anlagen
Annahme. Der User misst den Horizont an einem Standpunkt (die Foto-Serie hat genau einen Anker-Standort pro Array). Das gemessene Horizont-Profil wird als Mittelwert über die gesamte Modulgruppe angenommen — die App modelliert keinen über die Anlagenfläche wandernden Schatten.
Wann ist das gültig? Wenn der Schattenwerfer weit weg ist (Nachbarhaus, Baum, Berg ab ~20 m Abstand), wandert der Schatten geometrisch nur marginal über ein 1–2 m breites Balkonmodul. Die Punkt-Messung ist dann praktisch exakt — der Parallaxen-Fehler über die Modulbreite liegt unter der Mess-Genauigkeit der Kamera-Methode (±2–3 Pp, §3).
Wann wird es ungenau? Zwei Fälle:
- Naher Werfer (eigene Brüstung, Fensterleibung, Schornstein in < 1–2 m): kleiner Versatz des Mess-Standorts ändert den verdeckten Horizont-Sektor stark. Die Messung gilt dann nur für genau den Punkt, an dem fotografiert wurde.
- Ausgedehnte Anlage (Aufdach-Set mit 4+ Modulen über mehrere Meter Breite): ein naher Schatten kann tagsüber nur Teile der Fläche treffen.
Richtung des Fehlers. Das Modell überträgt den am Mess-Punkt erfassten Verlust pauschal auf alle Module der Gruppe. Bei Teilverschattung mit String- oder Modul-Wechselrichter, der die unverschatteten Module trotzdem weiter einspeisen lässt, ist das eher konservativ (überschätzt den realen Verlust). Die Brand-Linie „ehrlich, nicht schöngerechnet” toleriert diese Richtung bewusst — lieber konservativ als optimistisch.
Empfehlung an den User. Im Zweifel an der Stelle mit der stärksten Verschattung messen (Worst-Case-Anker), nicht an der freiesten.
UX-Surface (Sprint B, B1, 2026-05-28). Diese Grenze wird nicht still übergangen: Im Shading-Capture-Step erscheint bei großen/verteilten Anlagen (Trigger: Gesamt-peakPowerKw > 1,6 ODER additionalPanelArrays.length >= 2) ein einmaliger, dismissibler Info-Banner (ExtendedArrayNotice, SharedPreferences-Flag extended_array_notice_seen). Tap führt auf die Methodik-Sektion „Ausgedehnte Anlagen: eine Messung, ein Mittelwert”. Kein Modell-Change — reine Transparenz.
3.2 Horizont-Cutoff & Verschattungs-Darstellungs-Bänder
Horizont-Cutoff minRelevantElevationDeg = 2° (K7R2). ShadingMask.fromCaptures verwirft alle Masken-Punkte mit Elevation ≤ 2°. Grund: beim manuellen Horizont-Zeichnen entsteht am unteren Bildrand fast immer ein Mini-Wedge, weil der User die Linie nicht „leer” lassen kann — der Cutoff verhindert, dass selbst ein freier Himmel einen Schein-Verschattungsanteil trägt. Konsequenz: Ein bewusst freier/niedriger Horizont ergibt eine leere Maske (points.isEmpty), obwohl der User gemessen hat. Deshalb wird „gemessen?” im Export nicht aus der gefilterten Maske abgeleitet, sondern aus dem Rohfeld shadingCaptures.any((c) => c.hasHorizon) (shadingMeasured-Signal). So behauptet das Detail-PDF nie fälschlich „keine Messung erfasst”, wenn eine Horizontlinie gezeichnet wurde (Anti-Hype-Ehrlichkeit).
Darstellungs-Bänder (reine Präsentations-Konvention, kein Modell-Eingriff). Der objektive Jahres-Verschattungsverlust shadingLossPercent (§11.2 / L_global aus 3.7) wird im PDF immer als führende Zahl gezeigt, wenn gemessen wurde; ergänzt um eine Wort-Stufe, damit die Zahl einordbar ist. Die Stufe ist an die berechnete Zahl gebunden, also reproduzierbar und nicht subjektiv:
shadingLossPercent | Wort-Stufe |
|---|---|
| < 3 % | keine relevante Verschattung |
| 3–10 % | mittlere Verschattung |
| > 10 % | deutliche Verschattung |
Bei freiem Horizont (leere Maske) ist shadingLossPercent ≈ 0 % → „keine relevante Verschattung” — die ehrliche Aussage „gemessen, kein relevantes Hindernis”. Code: shadingBandForLoss in pdf_export_service.dart. Die Schwellen sind eine Anzeige-Kalibrierung; die führende Prozent-Zahl bleibt immer sichtbar, das Wort versteckt nichts.
4. Lastprofil — BDEW H25 + Block-Addition (Endnormierung)
Was rechnet die App? Erzeugt ein 24×12-Lastprofil (Watt × Stunde × Monat) für die Eigenverbrauchs-Überlagerung. Beschreibt, wie viel Strom ein typischer Haushalt zu welcher Uhrzeit zieht.
Eingaben. Haushaltstyp, Anwesenheitsmuster, verschiebbare Geräte (Waschmaschine, Spülmaschine, Trockner) mit Zeitfenster, punktuelle Geräte (Herd, Ofen, Wasserkocher), Großverbraucher (E-Auto, Wärmepumpe, Durchlauferhitzer, Klimagerät — Letzteres siehe §4.10), optional explizit eingegebener Jahresverbrauch aus Stromrechnung. (Die früheren Dauerverbraucher/Grundlast-Geräte — Kühlschrank/Router/Standby/NAS — sind 2026-07-19 entfallen: in H25 implizit enthalten, wirkten nur als energie-neutrale Abflachung, siehe §14.)
Modell & Formel.
Methodik-Revision (Sprint Beta-Feedback D, 2026-05-28 — implementiert): Diese Sektion beschreibt Variante A — Block-Addition + Endnormierung (die ursprüngliche Spec,
lastprofil_new.md§7). Sie löst die zuvor implementierte Delta-gegen-Default-Baseline-Methode ab (Geräte-Energie wurde an einem fiktiven „Default-Geräte-Standort” subtrahiert und am User-Standort wieder addiert). Begründung siehe „Wahl der Werte” unten. Implementations-Stand: Der Code-Refactor (PR2,ba05ef3) ist gelandet —load_profile_service.dartrechnet durchgehend Variante A; Methodik-Seite und App-Verhalten stimmen überein.
Die Pipeline kombiniert eine BDEW-H25-Referenzkurve mit einer summen-neutralen Anwesenheits-Umformung und rein additiven Geräte-Events, normiert die gesamte Haushaltskurve auf das Jahresziel und addiert die Großverbraucher separat. Pipeline in Pseudo-Code:
(4.1) Pipeline pro Monat m
L_house_raw(h, m) = L_ref(h, m) + ΔPresence(h)
+ ΔShiftable(h) + ΔPunctual(h)
L_house(h, m) = L_house_raw(h, m) · ( target / rawAnnual )
L_addons(h, m) = L_ev(h) + L_heatpump(h, m) + L_waterHeating(h) + L_ac(h, m)
total(h, m) = max( 0, L_house(h, m) + L_addons(h, m) )
L_ref(h, m) ist die BDEW-H25-Stundenkurve für Monat m (Watt pro 1.000 kWh/Jahr). Alle Geräte-Terme Δ… sind reine Additionen (kein Baseline-Vergleich, keine Subtraktion an einem fiktiven Default-Standort). Die Endnormierung auf target macht die Geräte energie-neutral: sie verschieben die Zeitstruktur der Kurve, nicht die Jahresenergie — denn der eingegebene Jahresverbrauch enthält die Haushaltsgeräte bereits implizit (H25 ist Top-Down-aggregiert, siehe „Wahl der Werte”). Das ist das Anti-Doppelzählungs-Prinzip.
(4.2) Endnormierung Haushaltsstrom (in BEIDEN Fällen aktiv)
rawAnnual = Σ_h,Tage L_house_raw(h, m) / 1000 [kWh/Jahr]
effectiveAnnual (LITERAL autoritativer Jahres-Basiswert):
effectiveAnnual = householdAnnualKwh
?? country.householdAnnualKwh[householdType]
Kein Auto-Wachstum. Der im Feld immer vorbelegte Preset (bzw. der
editierte Wert) IST der Basiswert. „Enthaltene" Großverbraucher
stecken INNERHALB dieses Werts (reduzieren den Sockel), nicht darin
enthaltene liegen additiv darüber.
target = max( 0, effectiveAnnual − Σ_includedAddons )
L_house = L_house_raw · ( target / rawAnnual )
Σ_includedAddons = Energie aller Add-ons mit *AlreadyIncluded == true.
Wahl der Werte — literal autoritativ (2026-07-19, Auto-Wachstum zurückgedreht). Der Jahresverbrauch-Wert ist literal autoritativ: 2500 heißt 2500. Das Eingabefeld trägt immer den Haushaltsgrößen-Preset (editierbar) — der null-Fall existiert im realen Flow nicht, ?? country.householdAnnualKwh[…] ist nur ein Rechen-Fallback. Die als „enthalten” markierten Großverbraucher stecken innerhalb des Werts (reduzieren den Haushaltssockel), nicht-enthaltene liegen additiv darüber:
- Regelfall:
target = max(0, effectiveAnnual − Σ_includedAddons);annualKwh = effectiveAnnual + Σ_{nicht-enthalten}. - Inkonsistenz-Fall (Σ_includedAddons > effectiveAnnual, z. B. WP 2900 „enthalten” bei Paar-Preset 2500):
targetklemmt auf 0 (max(0, …)) plus ehrlicher Inline-Hinweis im Verbrauchs-Screen (householdConsumptionInconsistent, Anti-Hype, keine stille Degradation). Feuert auch auf dem Preset-Wert (keine „nur bei User-Eingabe”-Ausnahme mehr).
*AlreadyIncluded-Defaults auf false gekippt (2026-07-19). Alle vier Flags (EV/WP/WW/AC) starten jetzt auf false = additiv obendrauf. Der Preset ist eine generische Haushaltszahl, die keinen konkreten Großverbraucher kennt; ein aktivierter WP/WW/EV/AC liegt also per Default über dem Preset und formt die Kurve sichtbar. Erst wenn der User den Feldwert auf seine echte, geräte-inklusive Rechnung setzt, markiert er das Gerät als „schon enthalten”. Früher waren WP/WW auf true vorbelegt (Rest der Auto-Wachstum-Logik) — das hätte im literalen Modell jedes Paar mit Wärmepumpe sofort in den Inkonsistenz-Fall gedrückt. Historie: Das Auto-Wachstum (Entscheidungslog des Vorgänger-Plans) wurde nach Gerätetest zurückgedreht, weil der „schon enthalten”-Toggle auf einem Preset-Wert die Kurve nicht bewegte (nur die Zahl). Code: LoadProfileService.effectiveHouseholdAnnualKwh / ._effectiveAnnual / .householdConsumptionInconsistent.
Länder-Default wird in den Service durchgereicht (Divergenz-Fix 2026-07-19). country.householdAnnualKwh[householdType] kommt aus country_defaults.json und wird über den optionalen Parameter countryHouseholdKwh an dailyProfileWatts/referenceProfileWatts/monthlyProfilesWatts/annualKwh (und die Aufrufer yield_provider, self_consumption_service, config_summary_section) durchgereicht. Vorher normierte der Service gegen den DE-Fallback, während die UI den länderspezifischen Default anzeigte — angezeigt ≠ gerechnet.
Anders als die abgelöste Delta-Methode wird die Endnormierung immer auf die vollständige Haushaltskurve angewandt (inkl. der additiven Geräte-Events) — auch ohne explizit eingegebenen Jahresverbrauch (dann gegen den aufgelösten Länder-Default). Nur so gilt die Doppelzählungs-Freiheit auch im „kein-Bill”-Fall.
Die Endnormierung wirkt multiplikativ (jede Stunde × gleicher Faktor target/rawAnnual), nicht als absoluter Gleich-Abzug. Das ist eine bewusste Entscheidung (2026-05-28): so bleibt die H25-Tagesform erhalten und keine Stunde wird negativ. Folge: ein zusätzliches Gerät dämpft seinen eigenen Block-Peak proportional zur Geräte-Energie leicht mit — gewollt, denn laut Original-Spec (lastprofil_new.md §4) ändern Geräte „primär die Zeitstruktur, nicht unkontrolliert die Energiemenge”. Die intuitive Alternative (Geräte-Block in voller Leistung + absolut gleichmäßiger Abzug von den übrigen Stunden) wurde verworfen, weil sie niedrige Nachtstunden unter 0 drücken kann und damit die < 0 → 0-Klemme — das Symptom-Pflaster der Delta-Methode — wieder einführen würde.
Warum Block-Addition statt Delta-gegen-Baseline (Anti-Hype-Begründung). Die abgelöste Methode berechnete eine Default-Geräte-Konfiguration pro Haushaltsgröße, subtrahierte deren Energie an einem fiktiven „Default-Standort” und addierte die User-Geräte am User-Standort. Das ist methodisch fundament-schwach und wurde 2026-05-27 verworfen:
- BDEW H25 ist Top-Down über tausende Haushalte aggregiert. Es existieren keine identifizierbaren Geräte-Slots im Profil — jede Subtraktion an einem „Default-Waschmaschinen-Standort” ist eine frei erfundene Annahme ohne empirische Grundlage. Sie erzeugte zudem sichtbare „Dellen”, sobald das User-Zeitfenster vom Default abwich (lokale Subtraktion → Negativwert → Klemme/Re-Normierung verformte die Kurve).
- De-Facto-Standard synthetischer Lastprofil-Modelle (oemof demandlib, PVsyst Presizing, HTW Stecker-Solar-Simulator) ist Block-Addition + Endnormierung.
- Anti-Hype-Konflikt: Die Delta-Fiktion lässt sich dem User nicht ehrlich als methodisches Fundament erklären. Block-Addition + Endnormierung schon: „Geräte verschieben die Zeitstruktur; deine Jahresenergie bleibt das, was du eingegeben hast.”
Doppelzählungs-Bewusstsein (User-Kommunikation). Weil H25 die üblichen Haushaltsgeräte bereits implizit enthält, ändert das Hinzufügen eines verschiebbaren Geräts primär die Form, nicht die Jahresmenge. Konsequenz für den User: Wer ein Gerät modelliert, das nicht in der bisherigen Stromrechnung steckte (z. B. ein neu angeschaffter Trockner), sollte den Jahresverbrauch entsprechend höher angeben — sonst wird die zusätzliche Energie durch die Endnormierung wieder „weggeteilt”. Echte Zusatzlasten (E-Auto, Wärmepumpe, Durchlauferhitzer) sind davon ausgenommen — sie liegen außerhalb des normierten Haushalts-Buckets (siehe L_addons).
(4.3) Anwesenheits-Delta — Direkt-Modulation (ab 2026-07-17)
W = ∅ oder unknown ∈ W oder varying ∈ W
→ ΔPresence ≡ 0 (Early-Exit)
sonst:
c_W(h) = max { curve_w(h) | w ∈ W } (W = User-Set)
ΔPresence(h) = L_presence(household) · zeroMean(c_W)[h]
mit zeroMean(c)[h] = c(h) − (1/24) · Σ c(h')
und L_presence(household) ∈ {100, 150, 250, 350} W
(single / couple / familySmall / familyLarge).
Die zeroMean-Operation hält die Tagessumme von L_house_raw stabil — ΔPresence verschiebt nur die Form der Stunden, nicht die Tagesenergie. Jedes gewählte Fenster hebt die Kurve dort sichtbar an; alle übrigen Stunden sinken uniform leicht (zero-mean).
Ablösung der Baseline-Delta-Methode (2026-07-17, iPad-Befund). Vorher galt ΔPresence = L · (zeroMean(c_W) − zeroMean(c_W_baseline)) mit W_baseline = {morning, evening} aus LoadProfile.defaultFor. Diese Baseline war im UI unsichtbar, mit zwei nicht nachvollziehbaren Folgen: (a) „nur Abends” wählen erzeugte eine Morgen-Delle statt eines Abend-Buckels (Abends war schon in der Baseline, die Morgen-Annahme wurde subtrahiert); (b) {morning, evening} explizit wählen war identisch mit „nichts wählen” (Delta 0) — die User-Information wurde verworfen. Die Direkt-Modulation macht jede Auswahl lokal sichtbar und erklärbar. Bewusster Trade-off: Die alte Methode inferierte Abwesenheit (Fenster nicht gewählt ⇒ dort gezielt weniger Last am H25-Peak) — statistisch vertretbar, aber unsichtbar. Die Direkt-Modulation senkt Nicht-gewählte Stunden nur uniform; für z. B. Pendler-Haushalte bleibt die H25-Morgenlast damit leicht überschätzt (minimal optimistischere Morgen-PV-Überlappung, Größenordnung ±30–80 W). Akzeptiert als Preis für Nachvollziehbarkeit (User-Entscheidung 2026-07-17). Eigenschaft am Rand: Auswahl aller sechs konkreten Fenster (inkl. night) ergibt eine nahezu flache c_W ⇒ Delta ≈ 0 ⇒ BDEW-Form; ohne night eine Tagsüber-Anhebung. Konsequenz der Umstellung: LoadProfile.defaultFor trägt jetzt presenceWindows = {unknown} (keine Anwesenheits-Behauptung), damit der unangetastete Default die pure Statistik liefert (No-Op-Kontrakt); dito das fromLegacy-Mapping homeOffice: none.
Early-Exit für unknown/varying/leeres Set. „Weiß nicht” und das leere Set bedeuten keine Information — die ehrliche Antwort ist die unmodulierte Statistik (Δ ≡ 0), konsistent mit der TimeWindow-Tabelle in §5 („BDEW-H25 unmoduliert”; Fix 2026-07-05). Seit 2026-07-17 gehört auch varying („Wechselnd”) dazu: ein über das Jahr wechselndes Muster ist statistisch genau die Durchmischung, die BDEW H25 bereits abbildet — die frühere handgetunte Tagsüber-Plateau-Kurve schob Last ohne empirische Grundlage in die PV-Zeit (schöngerechnete Überlappung, Anti-Hype-Bruch; User-Einwand 2026-07-17). Im UI sind „Wechselnd” und „Weiß nicht” deshalb zu einem Chip zusammengelegt (zwei Chips mit identischer Wirkung wären Schein-Differenzierung); der Enum-Wert varying bleibt für JSON-Kompatibilität und die Geräte-Zeitfenster erhalten. Regressionsschutz: load_profile_time_window_test.dart ({unknown}-, {varying}- und Leer-Set-Tests gegen exakte Default-Kurven-Gleichheit; „nur Abends”-Pin auf Delta-Maximum in 17–20; Energie-Neutralitäts-Pin).
Kein Geräte-Default — Detail-Startkurve == pure BDEW (2026-07-18, Gerätetest). Der No-Op-Kontrakt gilt seit diesem Befund nicht nur für presenceWindows, sondern auch für die Geräte-Sets: LoadProfile.defaultFor liefert für alle Haushaltstypen leere shiftableDevices/punctualDevices (ev: none, heatPump: none, instantWaterHeater: false; die Grundlast-Geräte sind 2026-07-19 ganz entfallen, siehe §14). Vorher waren Waschmaschine, Spülmaschine, Herd, Wasserkocher u. a. je nach Haushaltstyp vorgehakt — im Fast-Modus compute-seitig gestrippt (Ergebnis = BDEW), aber sobald der User in den Detail-Modus wechselte, verformten diese Annahmen die Kurve weg von BDEW, ohne dass der User sie gewählt hätte. Das ist ein Anti-Hype-Bruch (die App trifft stille Geräte-Annahmen). Jetzt ist die Detail-Startkurve == referenceProfileWatts (pure BDEW-H25, skaliert auf die sichtbare Jahres-kWh des Haushaltstyps); jedes Geräte-Shaping ist Opt-in. shiftableSchedule bleibt als Fenster-Vorschlag je Haushaltstyp erhalten (kleine Haushalte abends, Familien nachmittags) — inert, solange das Gerät nicht aktiviert ist, und greift erst bei bewusster Aktivierung. Die Jahres-kWh-Skalierung nach Haushaltstyp (defaultHouseholdAnnualKwhFor) bleibt: eine sichtbare, editierbare Größe, keine versteckte Formannahme.
(4.4) Verschiebbare Geräte — Energie-Verteilung
Pro Gerät d mit Energie E_d [kWh/Zyklus], Cycles N_d [/Jahr] und
User-Fenster-Set W_d:
dailyKwh = E_d · N_d_effective / 365
share = dailyKwh / |W_d| (gleichmäßig auf alle Fenster)
Für jedes w ∈ W_d:
Block = [start_w, end_w) (Block-Breite siehe Tabelle)
Per Stunde h in Block:
ΔShiftable(h) += share · 1000 / (end_w − start_w) [W]
Energiewerte E_d [kWh/Zyklus]:
Waschmaschine 1.0, Geschirrspüler 1.2, Trockner 2.0.
Cycles N_d [Default pro Jahr, haushaltsabhängig]:
siehe Tabelle „Geräte-Zyklen je Haushaltstyp" unten.
N_d_effective berechnet sich aus N_d über den Sanity-Cap (4.4a).
(4.4a) Sanity-Cap — entkoppelte Haushaltsgröße ↔ Jahresverbrauch
defaultYearlyKwh = Σ_{d aktiv} E_d · N_d
maxAllowedKwh = max(0, effectiveAnnual − Σ_includedAddons) · 0.35
Wenn defaultYearlyKwh > maxAllowedKwh:
scale = maxAllowedKwh / defaultYearlyKwh
N_d_effective = round( N_d · scale ) für alle d
reducedFromDefault = true
sonst:
N_d_effective = N_d
reducedFromDefault = false
Ohne `householdAnnualKwh` (= null) oder leerer Geräte-Liste: Cap
ist inaktiv, N_d_effective = N_d.
Cap-Basis = Haushalts-Bucket (Konsistenz-Fix 2026-07-17, literal 2026-07-19). Der Cap rechnet gegen dieselbe Basis wie die Endnormierung (4.2): max(0, effectiveAnnual − Σ_includedAddons) — der literale Jahreswert minus der als „enthalten” markierten Add-ons. Vorher wurde die rohe Rechnung verwendet — bei großen „bereits enthaltenen” Add-ons (z. B. Rechnung 4000 kWh mit 2000 kWh WP enthalten) erlaubte der Cap dann bis zu 70 % des tatsächlichen Haushalts-Buckets an Geräte-Energie, und der H25-Hintergrund kollabierte nach der Normierung — genau das, was der Cap verhindern soll.
Begründung: Default-Cycles N_d sind aus Stromspiegel 2024 als Median-Verhalten pro Haushaltstyp kalibriert. Wenn Haushaltsgröße und tatsächlicher Jahresverbrauch entkoppelt sind (z. B. 5-Personen-Haushalt mit 2800 kWh durch sparsame Geräte und Verhalten, oder Single mit 4500 kWh durch Aquarium + Home-Office), würde die explizit modellierte Geräte-Last einen unplausibel großen Anteil des Budgets einnehmen — nach der Endnormierung schrumpft der Hintergrund (Beleuchtung, Kleinverbraucher, Standby) dann proportional auf eine unplausible Größe.
Rolle des Caps unter Variante A. Die Mass-Conservation (Σ L_house = target) ist durch die Endnormierung (4.2) strukturell garantiert — der Cap ist daher kein Modell-Korrektor mehr, sondern eine reine Plausibilitäts-Schranke: Er verhindert, dass ein implausibel hoher Geräte-Zyklen-Wert bei niedrigem deklariertem Jahresverbrauch den H25-Hintergrund nach der Normierung kollabieren lässt. Er drosselt die Cycles proportional und macht das als Hinweis im HouseholdAssumptionsSheet sichtbar (reducedFromDefault-Flag). Die früheren Symptom-Pflaster der Delta-Methode (< 0 → 0-Klemme pro Komponente, 50-W-Floor als harte Korrektur) sind unter Variante A nicht mehr nötig — der einzige verbleibende Guard ist die äußere max(0, L_house + L_addons)-Klemme (Add-ons können stundenweise das Haushalts-Bucket überragen).
Geräte-Zyklen je Haushaltstyp (N_d). Quellen: Stromspiegel 2024 / VDEW-Erhebung, linear in Personenzahl skaliert.
| Gerät | Single | Couple | FamilySmall (3–4) | FamilyLarge (5+) |
|---|---|---|---|---|
| Waschmaschine | 90 | 180 | 280 | 400 |
| Spülmaschine | 120 | 240 | 380 | 600 |
| Trockner | 60 | 150 | 230 | 320 |
Vorher (bis 2026-05-23) implizit 365/Jahr für jedes Gerät — entsprach 1 Waschgang täglich für jeden Haushalt. Faktor 2–6 zu hoch, je nach Haushaltsgröße. Die zu hohen Tagesenergien führten zu einem sichtbaren Bug im Detail-Mode: Verschiebung der Geräte aus dem Evening-Slot zog die Per-Stunde-Subtraktion über den H25-Stundenwert, der Klemmen-auf-0-Pfad in _householdRawWatts schluckte Energie, und die anschließende Re-Normalisierung pumpte den Afternoon-Peak auf ~1,3 kW während der Abend auf 0 fiel.
Kontrakt (ab Variante A, 2026-05-28). Weil verschiebbare und punktuelle Geräte rein additiv modelliert werden (kein Baseline-Abzug mehr), kann das Geräte-Modell keine Stunde unter den H25-Referenzbeitrag drücken. Die frühere Aussage „keine Stunde unter 50 W” ist damit eine strukturelle Folge statt eines Kalibrierungs-Kontrakts: die Endnormierung skaliert die gesamte Kurve uniform, der H25-Hintergrund bleibt in jeder Stunde präsent. Die < 0 → 0-Klemme pro Komponente entfällt (war ein Symptom-Pflaster der Delta-Methode). Abgesichert durch Unit-Tests in load_profile_service_test.dart: (a) Geräte-Verschiebung erzeugt keine lokale Delle (nur Peak-Verschiebung), (b) Hinzufügen eines Geräts bei gleichem Jahresziel skaliert die Restkurve uniform runter, (c) Mass-Conservation Σ L_house = target ± 0,5 %.
(4.5) Punktuelle Geräte — fester 1-h-Block
Pro Gerät p mit (start, end, E_p):
Per Stunde h in [start, end):
ΔPunctual(h) += E_p · 1000 / (end − start)
Werte: Herd (18–19, 1.0 kWh), Ofen (18–19, 0.7 kWh),
Wasserkocher (7–8, 0.15 kWh).
(4.6) Add-on: E-Auto
dailyKwh = effectiveEvAnnualKwh / 365
Per Stunde h im Lade-Fenster [start_ev, end_ev):
L_ev(h) += dailyKwh · 1000 / (end_ev − start_ev)
Fenster: daytime (11–15), evening (19–23), pvLed (12–14).
(4.7) Add-on: Wärmepumpe — Tagesform × Saisonfaktor
dailyKwh(m) = ( effectiveHeatPumpAnnualKwh / 365 ) · s(m)
L_heatpump(h, m) = wp_shape(h) · dailyKwh(m) · 1000 / Σ wp_shape
(4.8) Saisonfaktor s(m), Cosinus-Schwinger mit Brauchwasser-Floor
s(m) = max( 0.25, 1.0 + 0.85 · cos( 2π · (m − 1) / 12 ) )
Eigenschaften:
- Peak im Januar: s(1) = max(0.25, 1.85) = 1.85
- Tief im Juli: s(7) = max(0.25, 0.15) = 0.25
- Σ_m s(m) ≈ 12 → Tagesform-Summe entspricht dem über
effectiveHeatPumpAnnualKwh gemeldeten Jahreswert.
wp_shape(h) ist eine 24-Stunden-Tagesform mit Morgenspitze
(h=6..8: 1.0) und Abendspitze (h=17..19: 1.0), Nachts/Mittag
gedämpft.
(4.9) Add-on: Durchlauferhitzer
dailyKwh = effectiveWaterHeatingAnnualKwh / 365
Tagesform mit Morgenpeak (h=6..8: 2.0/2.0/1.0) und Abendpeak
(h=18..21: 1.0). Normalisiert auf dailyKwh:
L_water(h) = ww_shape(h) · dailyKwh · 1000 / Σ ww_shape
Zeitfenster-Modell (TimeWindow, ab Sprint J 2026-05).
Anwesenheit und verschiebbare Geräte verwenden denselben Enum TimeWindow mit Set-basierter Auswahl. Acht Fenster, sechs konkret + zwei spezielle:
| Fenster | Stunden (24 h) | Typische Nutzung |
|---|---|---|
morning | 06–09 | Frühstück, Schulvorbereitung |
forenoon | 09–12 | Late-Morning-Pause |
noon | 12–14 | Mittagessen |
afternoon | 14–17 | Heim-Schule, Nachmittag |
evening | 17–21 | Feierabend, Abendessen |
night | 21–06 | Schlaf + nachttarif (z.B. WP) |
varying | — | „Wechselnd / weiß nicht”; BDEW-H25 unmoduliert (Δ 0) |
unknown | — | „Wechselnd / weiß nicht”; BDEW-H25 unmoduliert (Δ 0) |
Anwesenheits-Kurve aus einem Set<TimeWindow>. Jedes konkrete Fenster hat eine handgetunte 24-h-Kurve mit Plateau 0.9 in den aktiven Stunden, 0.5 in den unmittelbar angrenzenden Übergängen, sonst 0.2. varying und unknown erzeugen keine Modulation (Early-Exit in 4.3, Δ ≡ 0 — im UI ein gemeinsamer „Wechselnd / weiß nicht”-Chip, seit 2026-07-17).
Für ein Set werden die Kurven elementweise per Maximum kombiniert (keine Summierung, kein Renormieren — Zero-Mean übernimmt die Skalierung im Service). Damit ist {morning, evening} zwei sichtbare Peaks. Enthält das Set unknown oder varying, greift der Early-Exit (unmodulierte Kurve); die UI-Mutex-Logik schließt solche Misch-Sets ohnehin aus.
Verschiebbare Geräte: Energie-Verteilung. Pro Gerät ein Set Set<TimeWindow>. Die Geräte-Energie (Waschmaschine 1.0, Geschirrspüler 1.2, Trockner 2.0 kWh/Zyklus) wird gleichmäßig auf alle aktiven Fenster verteilt; jedes Fenster bekommt einen 2-h-Block:
| Fenster | Geräte-Block |
|---|---|
| morning | 07–09 (2 h) |
| forenoon | 10–12 (2 h) |
| noon | 12–14 (2 h) |
| afternoon | 14–16 (2 h) |
| evening | 17–21 (4 h) |
| night | 01–03 (2 h) |
| varying, unknown | 00–24 (gleichmäßig über den Tag) |
varying/unknown = 24-h-Gleichverteilung (Anti-Hype-Fix 2026-07-17). Vorher fielen beide auf einen 10–14-Uhr-Mittagsblock zurück („PV-freundlich, weil tagsüber”) — „ich weiß nicht, wann die Maschine läuft” wurde damit als beste Solarzeit gerechnet und schönte den Eigenverbrauch. Ohne Zeitinfo darf das Modell keine Formbehauptung machen: die Energie wird gleichmäßig über 24 h verteilt, die Kurve ändert sich nach der Endnormierung kaum (das ist die korrekte Aussage). Die Alternative „gar kein Block” (Energie bleibt implizit in der H25-Form) wäre mathematisch noch sauberer, wurde aber verworfen, weil eine sichtbar wirkungslose Checkbox mehr Erklärung bräuchte; die Effekt-Differenz ist nach der Endnormierung vernachlässigbar.
evening ist mit 4 Stunden breiter als die übrigen konkreten Fenster, weil die H25-Abendspitze sich über 17–21 erstreckt und eine 2-Stunden-Subtraktion (wie vor 2026-05-23) die Per-Stunde-Magnitude über den H25-Stundenwert getrieben hat. Das schmälere (19, 21)-Fenster wurde gemeinsam mit dem Cycle-Fix auf (17, 21) verbreitert.
Ein leeres Set fällt auf {varying} zurück. „Gerät nicht genutzt” wird durch Fehlen in shiftableDevices ausgedrückt (Checkbox in der UI), nicht durch ein leeres Window-Set.
Migration aus Pre-Sprint-J-Profilen. Alte JSON-Slots mit Single-Select presence bzw. DeviceTimePreference werden in LoadProfile.fromJson auf Sets gemappt. Tabelle: rare → {morning, evening}, morningEvening → {morning, evening}, morning → {morning, forenoon}, afternoon → {afternoon}, fullTime → {varying}, varying → {varying}. Für Geräte: none → ∅ (Gerät bleibt in shiftableDevices nur, wenn es im Slot vermerkt war), anytime → {varying}, sonst 1:1. Unbekannte Strings fallen auf {unknown} zurück, statt zu crashen.
Annahmen & Vereinfachungen.
- BDEW H25 ist für Deutschland kalibriert. Für AT/CH/NL/BE/FR/IT/ES/PL/CZ existieren publizierte Profile (siehe Backlog Sektion 13), aktuell fallen alle Nicht-DE-Länder auf BDEW H25 zurück. Profilform stimmt grob, aber:
- Mediterrane Länder (IT/ES) haben Spätabend-Spitzen (Abendessen 21–22 Uhr), die im H25-Profil bei 18–20 Uhr liegen.
- Skandinavien hat im Winter morgens/abends starke Heiz-Beleuchtungsspitzen.
- Deltas sind additiv und unkorreliert. Real wäre z.B. “Wärmepumpe an” oft korreliert mit “alle zuhause” — das ignoriert das Modell.
- Sommer/Winter-Unterschiede über die 12 Monatsprofile abgebildet, kein Wochentag/Wochenend-Unterschied (BDEW H25 vermischt diese in der typischen Woche).
Wahl der Werte.
- BDEW H25 für alle Länder als Fallback. Alternative wären landesspezifische Profile (siehe Backlog §13: AT APCS, CH VSE, FR Enedis RES1, NL NEDU E1A, BE Synergrid S21, IT ARERA, ES REE Tipo A, PL G11, CZ D02). Verworfen für TestFlight, weil die 9 zusätzlichen Profile jeweils Excel/PDF-Parsing + Validierung brauchen. H25 trifft Mitteleuropa gut; mediterrane Spätabend-Spitzen und skandinavische Winter-Beleuchtungs-Peaks fehlen heute systematisch.
- WP-Saisonfaktor-Amplitude 0,85 in (4.8). Empirisch gegen typische COP-Wetterkurven (Bundesverband Wärmepumpe e.V., Feldtest-Daten 2022–2024) kalibriert. Bei Amplitude 1,0 wäre der Sommer-Floor unter 0,25 gerutscht und nur durch den Floor gehalten worden; bei 0,6 wäre der Winterpeak zu schwach. Vorgänger (vor 2026-05) lautete
0,55 + 0,45·cos(…)— Mittel 0,55, Sommer-Tief 0,10. Das hatte zwei Probleme: die Profil-Verteilung deckte nur 55 % der semantischen Jahresenergie ab (Eigenverbrauchsrechner sah zu wenig WP-Last), und das Sommer-Tief 0,10 ignorierte den Brauchwasser-Bedarf. Neue Form ist um 1,0 zentriert (Σ_m s(m) ≈ 12) und hat einen 0,25-Floor. - Brauchwasser-Floor 0,25 in (4.8). Moderne WPs heizen ganzjährig Warmwasser, etwa 25–30 % der Jahresenergie (BWP-Studien). 0,25 ist die konservative untere Grenze — wer einen reinen Heizungs-WP-Modus ohne Warmwasser modellieren will, müsste den Floor senken.
- Anwesenheits-Plateau-Werte 0,9 / 0,5 / 0,2. Aktive Stunden 0,9, unmittelbare Übergänge 0,5, Rest 0,2. Heuristisch gewählt — wenn HTW-Berlin oder vergleichbare Quellen empirische 24-h-Profile pro Anwesenheits-Pattern publizieren, hier nachjustieren. Set-Kombi per
maxin (4.3) bleibt stabil; nur die Einzelkurven sind beweglich. - Geräte-Energien (Waschmaschine 1,0 / Geschirrspüler 1,2 / Trockner 2,0 kWh/Zyklus). Mittelwerte aus EU-Energielabels A++ bis A+++. Real-Streuung ±30 % pro Zyklus, aber für die Tagesform-Schätzung ausreichend.
- Geräte-Zyklen pro Jahr je Haushaltstyp (siehe Tabelle bei 4.4a). Stromspiegel 2024 / VDEW-Erhebung, linear in Personenzahl skaliert. Median-Verhalten, nicht Worst-Case. Power-User mit „Spüler 2× täglich” oder Schichtarbeiter-Haushalte sind nicht abgedeckt — der Premium-Wizard wird später Pro-Gerät-Override erlauben. Sanity-Cap (4.4a) bei 35 % Energie-Budget-Anteil verhindert Background-Kollaps bei entkoppelten Haushaltsgröße ↔ kWh-Kombinationen.
- WP-Tagesform
wp_shape(h)(24-h-Werte in_heatPumpHourly). Zwei Peaks (Morgens h6–h7 = 1,0 mit Auslauf h8 = 0,8; Abends h17–h18 = 1,0 mit Auslauf h19 = 0,9), Mittagstal (9–12, 0,4–0,6). Heuristisch gegen Smart-Home-Lastgang-Studien (BWP, Fraunhofer ISE) abgeschätzt. Nicht klima-spezifisch — in sehr kalten Wintern wäre die Lastkurve flacher (durchgehend hoch). - Anwesenheits-Watt L_presence(household) ∈ {100, 150, 250, 350} W. Grobe Mittelwerte für „alle zuhause”-Last (Beleuchtung, TV, Kochen, Kleinverbraucher), skaliert mit Haushaltsgröße. Untere Grenze des HTW-Berlin-Studien-Korridors.
Genauigkeit.
- Für Anteil-Berechnungen (Eigenverbrauchs-%) gut: ± 5 Prozentpunkte gegen reale Smart-Meter-Lastgänge (HTW-Berlin-Kalibrierung).
- Für absolute Stunden-kWh-Vorhersagen nur Größenordnung: Spitzen können um Faktor 2 daneben liegen, weil Real-Lastspitzen (Wasserkocher 2 kW für 3 Min) im Stundenmittel verschwinden.
Wo darf man nicht trauen?
- Mehrfamilienhäuser, gewerbliche Verbraucher, Smart-Home-Setups mit Lastmanagement.
- Saisonale Heizmuster außerhalb DACH (z.B. mediterrane Klimaanlagen-Spitzen im Sommer).
Quelle.
BDEW Standardlastprofile 2025 (öffentlich, https://www.bdew.de). Block-Addition + Endnormierung als De-Facto-Standard parametrischer Hybrid-Lastprofil-Modelle: oemof demandlib, PVsyst Presizing, HTW Stecker-Solar-Simulator (Eigenverbrauchs-Kalibrierung). Original-Spec: lastprofil_new.md §7. Code: load_profile_service.dart.
Re-Kalibrierung.
- Direkt-Modulation der Anwesenheit + ehrliche No-Info-Fallbacks (2026-07-17): Drei zusammengehörige Änderungen nach iPad-Gerätetest-Befund („Kurvenänderungen nicht nachvollziehbar”): (1) ΔPresence ohne Baseline-Term (4.3) — gewählte Fenster heben die Kurve lokal sichtbar an; (2)
varying≡unknown≡ Δ 0, UI-Chips zusammengelegt; (3) Shiftable-Fallbackvarying/unknownvon 10–14-Mittagsblock auf 24-h-Gleichverteilung (4.4). Dazu Cap-Basis-Konsistenzfix (4.4a) unddefaultFor/fromLegacy(none)auf{unknown}. Eigenverbrauchs-Quoten der unangetasteten Default-Profile bleiben unverändert (Δ 0 ⇒ pure H25-Form, HTW-Crosscheck-Pins stabil); Profile mit gesetzter Anwesenheit/„Wechselnd” ändern ihre Kurve sichtbar (dokumentiert im App-CHANGELOG). Details und Trade-off-Diskussion:docu/plans/archive/presence-direct-modulation.md. - Umstellung auf Variante A (Sprint Beta-Feedback D) — Gegencheck-Ergebnis (2026-05-28): Alt-vs-Neu-Vergleich der Eigenverbrauchs-Quote für die DACH-Default-Profile (Referenz-PV Berlin 800 Wp Süd/30°, ~760 kWh/Jahr) ergab eine deutliche Senkung statt der ursprünglich vermuteten leichten Anhebung: single −15,1 Pp (69,4 % → 54,3 %), couple −11,4 Pp (84,9 % → 73,5 %), familySmall −1,7 Pp (sättigt nahe der 0,85-Decke). Ursache ist nicht der Geräte-Additions-Methodenwechsel (auf den sich die alte „+1 bis +3 Pp”-Vermutung bezog), sondern der dominierende Null-Normierungs-Ziel-Wechsel: das unberührte Default-Profil normiert jetzt gegen
defaultHouseholdAnnualKwhFor(1500/2500/3500 kWh) statt gegen die alte interne Tabelle (1800/3000/4000) — kleinere Jahres-Last → weniger min(PV,Last)-Überlappung bei gleichem Klein-PV. Das ist genau die gewollte Option-A-Vereinheitlichung (beseitigt den versteckten ~17 %-Antipp-Sprung, §4.2); die neue Zahl ist konservativer und UI-konsistent und wurde als ehrlichere Quote akzeptiert (User-Entscheidung 2026-05-28).0,85-Jensen-Bias bleibt unverändert — der Shift kommt aus der Last-Magnitude, nicht aus der Aggregations-Korrektur. Pin als Regressions-Test:self_consumption_htw_crosscheck_test.dart. Offener Follow-up: echter HTW-Stecker-Solar-Simulator-Absolutwert-Abgleich (800 Wp Süd, 1500/2500 kWh) zur Bestätigung, dass 54 %/73 % im HTW-±5-Pp-Band liegen — der hier dokumentierte Wert ist Alt-vs-Neu-Delta, kein HTW-Absolut-Anker. - Bei Nachzug landesspezifischer Profile: H25 als Fallback behalten, neue Profile pro Land in
_referenceCountryCode()ergänzen. - Wärmepumpen-Saisonfaktor Untergrenze (aktuell 0.25) prüfen, wenn Brauchwasser-Anteil moderner WP > 35 % wird.
- TimeWindow-Kurven (0.9 / 0.5 / 0.2) sind heuristisch. Wenn HTW-Berlin oder vergleichbare Quellen empirische 24-h-Profile pro Anwesenheits-Pattern veröffentlichen, die Plateau-Werte nachjustieren. Die Set-Kombination per
maxbleibt stabil; nur die Einzelkurven sind beweglich.
4.9 Referenzlinie im Verbrauchs-Chart (gestrichelt) — reine H25-Form
Was zeigt die App? Das Tageslastprofil-Chart legt zwei Kurven übereinander: die persönliche Kurve (dailyProfileWatts, voll geformt durch Anwesenheit + Geräte + Add-ons) und eine gestrichelte Referenzlinie als neutralen Anker, an dem der User seine Abweichung abliest.
Modell. Die Referenzlinie ist die reine BDEW-H25-Tagesform, skaliert auf den User-Jahresverbrauch — ohne Anwesenheits-Modulation, ohne Geräte-Events, ohne Add-ons (LoadProfileService.referenceProfileWatts). Sie hängt damit nur von household und householdAnnualKwh ab und ist formstabil gegenüber allen User-Eingaben.
(4.9) Referenzlinie
ref(h, m) = L_ref(h, m) · ( target / Σ_year L_ref )
target = max( 0, effectiveAnnual − Σ_includedAddons ) (wie 4.2)
Normiert auf denselben Haushalts-Zielwert wie die persönliche Haushaltskurve (4.2): die Referenz zeichnet den Haushaltssockel in reiner H25-Form. Die als „enthalten” markierten Großverbraucher liegen als sichtbare Hügel der persönlichen Kurve darüber — so wird der Beitrag der Großverbraucher gegenüber dem statistischen Haushaltssockel direkt ablesbar (2026-07-19: Angleich an die Baseline-Auflösung; Länder-Default fließt über countryHouseholdKwh mit ein, angezeigt = gerechnet).
Begründung (Befund 2026-05-28, B1). Vorher zeichnete der Screen dailyProfileWatts(defaultFor(household)) als Referenz — das backte den Haushalts-Default-Geräte-Schedule (z. B. den Nachmittags-Buckel von defaultFor(familySmall/familyLarge), Waschmaschine/Spüler/Trockner auf 14–16 Uhr) in die „Referenz” ein. Es war kein State-Leck aus User-Eingaben (defaultFor ist const, der Service hält keinen mutierten State) — die Referenz trug die Default-Geräte-Form schon by-construction. Die reine H25-Form ist der ehrlichere, neutrale Anker. Reine Display-Änderung, kein Eingriff in die Modell-Pipeline.
B3-Bestätigung (Normierungs-Grundsatzfrage). Dass ein vom User gewählter Anwesenheits-/Geräte-Peak durch die multiplikative Endnormierung (4.2) anteilig wieder gedämpft wird, ist by-design und korrekt: Geräte/Anwesenheit ändern die Zeitstruktur, nicht die Jahresmenge (die gibt der User über die Stromrechnung vor). Kein Modell-Eingriff. Siehe (4.2).
UX-Surface Saison-Toggle + Solstiz-Info-Sheet (2026-07-18). Die Saisonalität wird nicht mehr erst auf der Ergebnis-Seite sichtbar: Das Verbrauchs-Chart zeigte vorher kommentarlos einen fest verdrahteten April-Tag (Monat 4), während Results zwei Solstiz-Charts rendert — mit WP/Klimagerät eine echte Überraschungslücke (Saisonfaktor 4.8/4.10 macht Januar-/Sommer-Peak, den die April-Kurve nicht zeigt). Jetzt: (1) Sommer|Winter-Chips über dem Vergleichs-Chart im Consumption-Step, die dieselben hemisphären-korrekten Solstiz-Monate anzeigen wie die Results-Charts (solsticeLabels, shared/util/), mit fester Y-Achse über beide Saisons (gleicher Ehrlichkeits-Mechanismus wie sharedDailyMaxY auf Results — kein Re-Normieren, der Größenunterschied bleibt sichtbar) und einer Caption, die den gezeigten Monat benennt und klarstellt, dass die Jahresrechnung alle 12 Monatskurven nutzt. (2) Info-Sheet an den Results-Solstiz-Charts (Info-Icon am Titel), das die drei Effekte erklärt (PV-Saisonhub ~4–5×, H25 sommerlastig Faktor 1,14, WP/Klima-Saisonfaktor) und zur Methodik verlinkt. Kein Modell-Change — reine Transparenz.
4.10 Klimagerät (Sommer-Pendant zur Wärmepumpe) — ab 2026-06-28
Was rechnet die App? Ein Klimagerät ist ein saisonaler Add-on-Verbraucher wie die Wärmepumpe (4.7), nur mit invertierter Saisonalität: Last konzentriert sich auf den Sommer (Kühlbedarf) und liegt tageszeitlich mittags bis nachmittags — also nahe am PV-Mittags-Überschuss. Strukturell identisch zur WP: Tagesform × Saisonfaktor, rein additiv in _addonsHourlyWatts, durchläuft dasselbe stündliche min(PV, Last)-Matching (Sektion 6).
Eingaben. AcType (Plug-Gerät ≤ 16 A Schuko vs. festinstalliert > 16 A Split/Multi-Split), AcUsagePattern (Betriebsmuster), optionaler Jahresverbrauch-Override acAnnualKwh, acAlreadyIncluded-Flag. Nur im Detail-Modus erfassbar (Premium-Gate über consumption_mode_screen); im Fast-Modus rechnerisch geleert (siehe 4.10-Anmerkung unten + §14). UI-Transparenz (seit 2026-07-04): Die Typ-Auswahl schreibt den Typ-Default sichtbar ins kWh-Feld (Prefill, überschreibbar) — gleiches Verhalten bei der Wärmepumpe (defaultAnnualKwhFor). Der Rechenpfad ist unverändert (effectiveAcAnnualKwh fiel auch vorher auf denselben Default zurück); es wird lediglich der stille Hintergrund-Default sichtbar gemacht (Anti-Hype).
Modell & Formel.
(4.10) Add-on: Klimagerät — Tagesform × Saisonfaktor
dailyKwh(m) = ( effectiveAcAnnualKwh / 365 ) · s_ac(m)
L_ac(h, m) = ac_shape_p(h) · dailyKwh(m) · 1000 / Σ ac_shape_p
Saisonfaktor s_ac(m): NICHT der WP-Kosinus (der streut zu viel
Last in Frühling/Herbst). Stattdessen ein Kühlgradtag-orientiertes
Monats-Gewichts-Array w(m) (DACH v1):
w = [0, 0, 0, 0.05, 0.30, 0.80, 1.0, 1.0, 0.40, 0.05, 0, 0]
(Jan..Dez; ~0 Nov–Mär, Ramp Apr/Mai, Peak Jul/Aug, Taper Sep/Okt)
Normiert auf den TAGE-GEWICHTETEN Jahresmittelwert = 1.0:
s_ac(m) = w(m) / ( Σ_m w(m)·days(m) / 365 )
→ Σ_m s_ac(m)·days(m)/365 == 1, d.h. die Tagesform-Summe reproduziert
effectiveAcAnnualKwh EXAKT (kein Clamp-Drift wie bei der WP).
Tagesform ac_shape_p(h) je Betriebsmuster p (AcUsagePattern):
- daytime: Ramp ab spätem Vormittag, Peak 14–16, Abend-Tail
(thermische Trägheit — Peak NACH dem Sonnen-Hoch).
- evening: tagsüber niedrig, Peak 18–21 (geringer PV-Overlap —
bewusst ehrlich, Abendbetrieb kommt aus dem Netz).
- pvLed: Smart-Plug/Timer kühlt vor, Peak 12–14 (max. PV-Overlap).
Annahmen & Vereinfachungen.
- SEER nicht explizit modelliert — der User gibt (optional) den Jahres-Stromverbrauch an; sonst greift der Typ-Default.
- Default-Jahresenergie (
defaultAcAnnualKwhFor): Plug-Gerät 400 kWh/Jahr, festinstalliert 900 kWh/Jahr. Bewusst konservativ (Anti-Hype). QUELLE-TODO: vor breitem Release gegen belastbare Quelle (co2online / Stiftung-Warentest-Raumklima) kalibrieren. - DACH-zentriert. Das CDD-Array ist auf Mitteleuropa kalibriert. Südeuropa (breitere Saison Mai–Sep, heißere Nächte → höhere Grundlast) ist Backlog und an die Temperatur-Modell-Verfeinerung (§13) gekoppelt.
acAlreadyIncludedDefault false — wie alle*AlreadyIncluded-Flags seit 2026-07-19 (EV/WP/WW/AC additiv obendrauf, siehe §4.2): der Preset kennt keinen konkreten Großverbraucher. Logik identisch (_includedAddonKwh/annualKwh).- Nur Kühlung modelliert (Winter-Heizen = Backlog). Das Klimagerät bildet die sommerliche Kühllast ab (Nov–März Gewicht 0,
_acMonthlyCoolingWeight). Ein reversibles Split-Gerät, das im Winter auch heizt, wird als Luft-Luft-Wärmepumpe (HeatPumpType.airAir) erfasst — nicht dasselbe Gerät doppelt (Klima und airAir-WP). Das Winter-Heizen der Klimaanlage direkt am AC-Enum ist Backlog und an das Temperatur-Modell (§13) gekoppelt (wie die Südeuropa-Kühlsaison).
Genauigkeit. Wie WP: für Eigenverbrauchs-Anteile brauchbar, absolute Stunden-kWh nur Größenordnung. Der ehrliche Differenzierer ist das Betriebsmuster — ein abends laufendes Klimagerät zeigt im min(PV,Last)-Matching deutlich weniger Eigenverbrauch als ein tagsüber/PV-geführtes.
Wo darf man nicht trauen? Mediterrane/südeuropäische Kühlmuster (zu schmale Saison im DACH-Array), gewerbliche Splitanlagen, Wärmepumpen im reversiblen Kühlbetrieb (würden doppelt gezählt, falls als WP und AC erfasst).
Quelle. Analog WP: Kühlgradtage-Konzept (DWD), Code: load_profile_service.dart (_acMonthlyCoolingWeight, _airConditioningSeasonFactor, _acHourly*). Tests: load_profile_service_test.dart (Saisonfaktor Klimagerät), self_consumption_service_test.dart (Betriebsmuster-Differenzierer).
Re-Kalibrierung. Default-kWh und das CDD-Array bei Süd-EU-Rollout pro Land überschreiben; Tagesformen nachjustieren, falls empirische Kühl-Lastgänge (z. B. HTW, enstoe) vorliegen.
Fast-Modus-Anmerkung. AC ist Detail-only und wird in LoadProfileService._effectiveProfileForMode im Fast-Branch auf none gesetzt (acAnnualKwh geleert) — anders als die übrigen Großverbraucher-Enums, die in beiden Modi sichtbar bleiben. Persistiert bleibt alles; detail → fast → detail ist verlustfrei (§14).
5. Eigenverbrauchs-Heuristik (Score-basiert)
Was rechnet die App? Schneller Schätzer für den Eigenverbrauchs-Anteil, sobald der User minimale Profil-Angaben gemacht hat — bevor das ausführliche Lastprofil aus Sektion 4 vorliegt.
Eingaben. Haushaltsgröße, Home-Office-Level, EV-Nutzung, elektrisches Warmwasser (ja/nein), Wärmepumpe (ja/nein).
Modell & Formel.
(5.1) Score-basierte Heuristik
score = 0.35
+ δ_household(householdSize)
+ δ_office(homeOffice)
+ δ_ev(evUsage)
+ (electricHotWater ? 0.05 : 0)
+ (heatPump ? 0.08 : 0)
EV_estimate = clamp(score, 0.25, 0.85) · 100 [%]
δ_household: 1 Person → −0.02, 2 → ±0, 3 → +0.03, 4+ → +0.05
δ_office: none → ±0, partial → +0.08, full → +0.15
δ_ev: none → ±0, occasional → +0.05, daily → +0.12
Der Startwert 0.35 repräsentiert einen „Standardhaushalt ohne Add-ons” — kalibriert gegen HTW-Berlin „Stecker-Solar-Geräte im Test” (Mittelwert über die DACH-Stichprobe). Maximaler theoretischer Score ohne Clamp: 0.35 + 0.05 + 0.15 + 0.12 + 0.05 + 0.08 = 0.80 — unterhalb der Obergrenze 0.85, der Clamp greift in der Praxis nur an der Untergrenze 0.25 (für „1-Personen-Haushalt, kein Home-Office, kein EV, kein Warmwasser, keine WP” → score = 0.35 − 0.02 = 0.33, knapp über 0.25).
Annahmen & Vereinfachungen.
- Kalibriert grob gegen HTW-Berlin Stecker-Solar-Studien (2018–2024).
- Linearer Score ohne Wechselwirkung — z.B. „Voll-Home-Office mit Wärmepumpe” wird in Wirklichkeit nicht streng additiv besser, weil tagsüber-Last gedeckelt ist.
Wahl der Werte.
- Startwert 0,35 — Mittelwert der HTW-Berlin-Stichprobe von Standard-Haushalten mit Stecker-Solar ohne Add-ons. Senkungs-Alternativen: 0,30 würde EV-Eigenverbrauch in Single-Haushalten unrealistisch tief drücken (oft schon clamped); Erhöhungs-Alternativen über 0,40 würden Multi-Add-on-Haushalte über 85 % heben, was ohne Speicher unphysikalisch ist.
- Lineare Additivität statt multiplikativ/wechselwirkend — Alternative wäre ein konditionales Modell (z.B. „Home-Office boost nur, wenn niemand zuhause war”). Verworfen, weil die UI dafür Fragen erfordern würde, die in der Schnell-Profil-Phase die UX kaputt machen. Das genauere Modell aus §6 hängt diesen Heuristik-Bias ohnehin ab, sobald der User das volle Lastprofil durchläuft.
- Clamp [0,25 … 0,85] — untere Grenze schützt vor unrealistisch niedrigen Werten bei pathologischen Eingaben; obere Grenze ist die physikalische Schranke ohne Speicher. Mit Speicher würde sich diese auf ~0,99 verschieben, siehe §13.
Genauigkeit. Ordnungs-richtig (± 10 Prozentpunkte gegen die genauere stündliche Überlagerung in Sektion 6).
Wo darf man nicht trauen? Atypische Lastprofile (Schichtarbeit, Vielreisende, Wohnung mit zentraler Versorgung).
Quelle.
Code: self_consumption_service.dart, Methode estimatePercent. HTW Berlin: “Stecker-Solar-Geräte im Test” (jährliche Updates).
Re-Kalibrierung. Stabil. Wird genutzt nur als UX-Default; sobald der User das Lastprofil-Detail durchläuft, übernimmt die genauere Methode in Sektion 6.
6. Stündliche Eigenverbrauchs-Überlagerung
Was rechnet die App? Genauere Eigenverbrauchsquote durch stündliche Überlagerung von PV-Profil und Lastprofil über alle 12 Monatsprofile.
Eingaben. LoadProfile (Sektion 4), monatliche stündliche PV-Erzeugung (Sektion 8), Country-Code (für Lastprofil-Referenz).
Modell & Formel.
(6.1) Stunden-Überlagerung pro Monat, über alle 12 Monate
selfUsed = Σ_{m=1..12} Σ_{h=0..23} min( PV(h, m), Load(h, m) )
· daysInMonth(m)
totalPV = Σ_{m=1..12} Σ_{h=0..23} PV(h, m)
· daysInMonth(m)
selfShare_raw = selfUsed / totalPV
(6.2) Jensen-Bias-Korrektur
selfShare = clamp( selfShare_raw · k_Jensen, 0, 1 ) · 100 [%]
mit k_Jensen = 0.85
PV(h, m) ist das stündliche PV-Watt-Profil aus §8 (nach Verschattung in §3 und Clipping in §9). Load(h, m) ist das Lastprofil aus §4. Beide werden in Watt geführt; min() liefert die instantan vom Haus verbrauchte PV-Leistung pro Stunde. Die Multiplikation mit daysInMonth(m) summiert auf Monatsenergie.
Jensen-Bias — warum die Korrektur 0,85 nötig ist.
min(·, ·) ist eine konkave Funktion. Die Jensen-Ungleichung sagt für jede konkave Funktion g und Zufallsvariablen X, Y:
E[ g(X, Y) ] ≤ g( E[X], E[Y] )
Mit g = min:
E[ min(X, Y) ] ≤ min( E[X], E[Y] )
Hochgerechnet auf unsere Stunden-Aggregation: Stunden-Mittelwerte von PV und Last sind die E[X], E[Y]. Der min davon (das, was wir in 6.1 rechnen) überschätzt den echten zeitlichen min-Mittelwert auf Minuten-Basis. Begründung anschaulich: Innerhalb einer Stunde gibt es Last-Spitzen (Wasserkocher 2.000 W für 3 min, Herd kurz auf 1.500 W), die das Stundenmittel anheben — gegen die das PV-Stundenmittel weiter gleicht. Auf Minuten-Auflösung wäre min in den Spitzen klein (PV-limitiert) und ausserhalb klein (Last-limitiert); auf Stunden-Auflösung sieht min ein „weiches” Mittel und wirkt höher.
Die 0,85-Korrektur ist die HTW-Stecker-Solar-Simulator-Regression: für 41 reale Haushalts-Lastgänge wurde der minütliche min-Mittelwert gegen den stündlichen verglichen. Mittelwert des Quotienten: 0,85 ± 0,05. Die Streuung kommt daher, dass Haushalte mit vielen kurzzeitigen Spitzen einen stärkeren Bias haben als Haushalte mit glatter Last.
Annahmen & Vereinfachungen — sonst.
- Keine Wettermodellierung pro Tag — die PV-Tagesform ist clear-sky-shaped, der Tagesertrag ist das PVGIS-Monatsmittel inklusive Wetter (siehe Sektion 8).
- Kein Speicher (Speichersimulation ist Phase 2).
- Pro Monat 30 (oder echte Tage) identische Tage, kein Wochenend-Unterschied.
Wahl der Werte.
- k_Jensen = 0,85 — kalibriert gegen HTW-Berlin „PV-Eigenverbrauch im Einfamilienhaus” + „Stecker-Solar-Geräte im Test”, minütliche Smart-Meter-Daten über 41 Profile. Sensitivität: Eine Verschiebung auf 0,80 oder 0,90 verschiebt
selfShareproportional um ±6 % relativ (≈ ±3 Pp absolut bei typischen 40–50 % Eigenverbrauch). - Stunden-Auflösung statt Minuten — wäre Alternative, würde Modell-Komplexität und Daten-Abhängigkeiten vervielfachen ohne klaren Genauigkeitsgewinn. Die HTW-Studie zeigt, dass eine konstante 0,85-Korrektur das Stundenmodell für DACH-Haushalte auf ±5 Pp gegen Minuten-Daten bringt — gleichwertig zur direkten Minuten-Simulation in der Praxis. Außerhalb DE solange das Lastprofil auf H25 zurückfällt, ist die Korrektur konsistent in sich, aber die absolute Eigenverbrauchsquote für IT/ES kann ±8 Pp daneben liegen.
- Identische Tage pro Monat statt Wochentag/Wochenend-Unterscheidung — BDEW H25 vermischt Werktag, Samstag und Sonn-/Feiertag in einer „typischen Woche” (5:1:1-Gewichtung). Wochenend-spezifischer Eigenverbrauch wäre ein Premium-Feature.
Genauigkeit. Innerhalb der HTW-Regression-Streuung: ± 5 Prozentpunkte gegenüber minütlich aufgelösten Smart-Meter-Daten. Klar besser als die Heuristik aus Sektion 5.
Wo darf man nicht trauen?
- Profile mit deutlich anderem Tagesschwerpunkt als BDEW H25 (z.B. mediterrane Spätabend-Spitzen) — die
0.85wurde für DE-Profile kalibriert. Außerhalb DE solange das Lastprofil auf H25 zurückfällt, ist die Korrektur konsistent in sich, aber die absolute Eigenverbrauchsquote für IT/ES kann ± 8 Prozentpunkte daneben liegen. - Setups mit Speicher: würden Eigenverbrauch um typisch +30 Prozentpunkte heben (komplett anderer Modellansatz).
Quelle.
Code: self_consumption_service.dart, Methode estimateFromLoadProfile. Referenz: HTW Berlin “PV-Eigenverbrauch im Einfamilienhaus” und “Stecker-Solar-Geräte im Test”.
Re-Kalibrierung.
_hourlyAggregationCorrectionpro Land neu kalibrieren, sobald landesspezifische Lastprofile (Sektion 13) integriert sind.- Faktor wechselt strukturell, wenn Speicher dazukommt (eigener Modellpfad).
7. Wirtschaftlichkeit — Year-1-Ersparnis & Amortisation
Was rechnet die App? Was spart der User im ersten Jahr (€/Jahr), nach wie vielen Jahren ist die Investition wieder drin, und wie viel CO₂ wird vermieden?
Eingaben. Jahresertrag (Sektion 11), Eigenverbrauchsanteil (Sektion 6), Strompreis [€/kWh], Einspeisevergütung [€/kWh], Investitionskosten [€], Land-CO₂-Faktor [kg/kWh].
Feste Modell-Konstanten (kein User-Input): Modul-Degradation d = 0,3 %/Jahr (siehe 7.2). Der User kann die Degradation nicht einstellen — der Economics-Step editiert ausschließlich Strompreis, Investitionskosten und Einspeisevergütung.
Modell & Formel.
(7.1) Year-1-Ersparnis
selfUsed = production · selfShare / 100
fedIn = production · ( 1 − selfShare / 100 )
S = yearlySavings = selfUsed · electricityPrice
+ fedIn · feedInTariff
(electricityPrice, feedInTariff pro Land aus §0.5.)
(7.2) Kumulative Ersparnis nach N Jahren mit Modul-Degradation
r = 1 − d / 100, mit d = panelDegradationPercentPerYear,
geclampt auf [0, 5] %
Yearly_n = S · r^(n−1) für Jahr n
Cumulative(N) = S · ( 1 − r^N ) / ( 1 − r ) (geometr. Reihe)
(7.3) Amortisationsdauer N* — Auflösung von Cumulative(N) ≥ invest
r^N = 1 − invest · ( 1 − r ) / S
Wenn 1 − invest · ( 1 − r ) / S ≤ 0:
N* = ∞ (Anlage amortisiert sich nie wegen Degradation)
Sonst:
N* = log( 1 − invest · ( 1 − r ) / S ) / log(r)
Sonderfall d = 0 (keine Degradation):
N* = invest / S
(7.4) CO₂-Einsparung (Year-1, landspezifisch)
co2SavingsKgPerYear = production · co2FactorKgPerKwh
production: Year-1-Ertrag aus §11.
co2FactorKgPerKwh: aus §0.5 (DE 0.38, FR 0.05, CH 0.03, …).
(7.5) Default-Investitionskosten (Fallback, wenn User nichts eingibt)
base = ( panelCount == 1 ) ? 350 € : 600 €
surcharge = ( wattPerPanel ≥ 500 ) ? panelCount · 100 € : 0 €
invest_def = base + surcharge
Beispiele:
1 × 400 Wp → 350 €
2 × 400 Wp → 600 €
2 × 800 Wp → 600 + 2·100 = 800 €
2 × 1000 Wp → 600 + 200 = 800 €
User-Eingabe in der UI übersteuert (7.5) immer.
(7.3) ist die Standard-Auflösung der geometrischen Reihen-Summe nach N. Logarithmen-Basis ist beliebig (sowohl Zähler als auch Nenner sind dieselbe Basis); im Code wird log der Standard-Bibliothek genutzt (natürlicher Logarithmus). Der Sonderfall d = 0 ist mathematisch der Grenzwert r → 1 und numerisch separat behandelt, weil log(1) = 0 eine Division-by-zero erzwingen würde.
Annahmen & Vereinfachungen.
⚠ Strompreis-Inflation wird bewusst NICHT modelliert. Der Year-1-Wert ist genau das, was der User auf der nächsten Jahresrechnung sieht. Damit ist die ausgewiesene Amortisationsdauer konservativ — bei steigenden Strompreisen amortisiert sich die Anlage real schneller, nie langsamer.
- Lineare Modul-Degradation, geclampt auf 0–5 %/Jahr. IEC-Datenblätter garantieren typisch 0,4–0,6 %/Jahr nach Jahr 1; der App-Default 0,3 %/Jahr (HTW-Stecker-Solar-Simulator) liegt darunter — bezüglich Degradation ist das Modell also eher optimistisch, nicht konservativ. Der Effekt auf die Amortisation ist klein (siehe „Wahl der Werte”).
- Einspeisetarif konstant über die Amortisationsdauer. In DACH realistisch (gesetzlich für 20 Jahre garantiert), in Ländern mit Net-Metering/Net-Billing nicht.
- Kein NPV/IRR/Diskontierung. Die App rechnet Cashflow-mäßig, nicht finanzmathematisch. Mit Zinssatz 3 % würde der NPV-basierte Amortisations-Punkt 2–3 Jahre später liegen.
- Default-Investitionskosten in (7.5) sind grobe EU-Richtwerte. User-Eingabe übersteuert.
Wahl der Werte.
- Year-1-Ersparnis statt mehrjährige Projektion. Brand-Versprechen „ehrlich, nicht schöngerechnet” — der ausgewiesene €-Wert deckt sich mit dem, was der User auf der nächsten Stromrechnung sieht. Eine Strompreis-Inflations-Annahme von z.B. 3 %/Jahr würde Amortisation um ~15 % kürzen, ist aber spekulativ. Strompreis-Inflations-Toggle ist als Premium-Hook angedacht (siehe §13).
- Degradation [0, 5] %/Jahr clamp. Realistische PV-Module: 0,4–0,6 %/Jahr laut IEC-61215-Datenblättern (linear nach Year 1, PID/LID); reale Feldmessungen moderner Glass-Glass-/TOPCon-Module liegen oft darunter. Der obere Clamp 5 % ist eine defensive Robustheits-Grenze auf die Modell-Konstante bzw. auf persistierte JSON-Config-Werte, kein User-Eingabe-Feld — der User kann
dnicht einstellen (der Economics-Step editiert nur Strompreis, Investition, Einspeisevergütung). Feste Modell-Konstante: 0,3 %/Jahr, übernommen vom HTW-Stecker-Solar-Simulator (Einzelquelle des Werts, Querverweis im dartdoc voneconomics_input.dart). Ehrlichkeits-Hinweis: 0,3 liegt unter der IEC-Garantie-Spanne und ist damit die eine bewusst nicht-konservative Annahme im Wirtschaftlichkeits-Block — die Sensitivität ist aber gering: 0,3 vs. 0,5 %/Jahr verschiebt die ausgewiesene Amortisation typischer Balkon-Setups nur um wenige Wochen. - Default-Invest-Tier-Struktur (7.5). Empirisch von balkon.solar, Idealo, Voltus, Klarsolar Mitte 2025 abgeleitet. Single-Panel-Setups starten bei ~350 € (Marktminimum eines lieferbaren 400-Wp-Sets mit 600-W-Wechselrichter); Multi-Panel-Setups starten bei ~600 € (2 × 400 Wp). Surcharge 100 €/Panel gilt ab 500 Wp, weil 500+ Wp-Module einen Preisaufschlag haben (Bifacial, Glass-Glass).
- CO₂-Faktor Year-1 statt Lifetime-Mittel. Begründung: das jährliche „eingesparte CO₂”-Statement ist im Anzeige-Kontext am verständlichsten („pro Jahr X kg”); die Lifetime-Mittelung würde alle Strommixe der nächsten 20 Jahre antizipieren — was niemand seriös vorhersagen kann (DE-Mix fällt mit Kohleausstieg, EU-Mix mit Erneuerbaren-Ausbau).
Genauigkeit.
- Year-1-Ersparnis: exakt im Rahmen der Eingangswerte (± 2 % wenn alle Inputs realistisch).
- Amortisation: ± 10 % wegen ignorierter Strompreis-Inflation und vereinfachter Degradation.
Wo darf man nicht trauen?
- Mehrjahres-Strompreis-Szenarien (Inflation/Spotmarkt-Volatilität).
- Anlagen mit Speicher.
- Gewerbliche Anlagen (Steuerlast, AfA, MwSt).
- Standorte ohne festgesetzte Einspeisevergütung (z.B. dynamische Marktpreise).
Quelle.
Code: economics_service.dart. CO₂-Faktoren: Umweltbundesamt DE, IEA Electricity Carbon Intensity 2024.
Re-Kalibrierung.
- CO₂-Faktor pro Land jährlich aus IEA-Update.
- Default-Investitionskosten alle 6 Monate gegen Marktbeobachtung (z.B. balkon.solar, idealo) prüfen.
- Strompreis-Inflations-Toggle ist ein angedachter Premium-Feature-Hook.
8. PV-Stunden-Schätzung (Tagesform aus Monats-kWh)
Was rechnet die App? Aus dem Monats-kWh des Solar-Providers (PVGIS/PVWatts/NASA POWER) ein 24h-Profil pro Monat erzeugen, damit Eigenverbrauchs-Überlagerung und Day-Curve-Chart funktionieren.
Eingaben. Standort, Zeitzone, Monats-kWh aus Solar-Provider, Panel-Azimut und -Tilt.
Modell & Formel.
Die Tagesform wird pro Stunde im 1-h-Raster berechnet (Sample-Zeitpunkt: jeweils Minute 30, also 10:30 für h=10). Pro Monat wird der 15. als Repräsentativtag verwendet.
(8.1) Angle of Incidence (identisch zu (3.2))
cos(AoI) = sin(α) · cos(β) + cos(α) · sin(β) · cos(γ − γ_p)
direct(h) = max( 0, cos(AoI) )
(8.2) Diffuser View-Factor des Moduls (isotrope Himmelsannahme)
f_view = ( 1 + cos(β) ) / 2
Eigenschaften:
- β = 0° (liegend): f_view = 1.0 (volle Hemisphäre sichtbar)
- β = 30° (Standard): f_view ≈ 0.93
- β = 90° (senkrecht): f_view = 0.5
(8.3) Diffus-Anteil pro Stunde
diffuse(h) = sin(α) · f_view
sin(α) ist die Globalstrahlungs-Approx auf der horizontalen
Bezugsfläche; f_view skaliert auf die geneigte Modulfläche.
(8.4) Direkt/Diffus-Mix (PV-Sizing-Faustregel)
irradianceShape(h) = 0.75 · direct(h) + 0.25 · diffuse(h)
(8.5) Saisonale Umgebungstemperatur (Mitteleuropa-Mittelwerte)
T_a(m) = 10.5 + 9.5 · sin( 2π · (m − 7) / 12 ) [°C]
Eigenschaften: T_a(1) ≈ 1 °C (Januar), T_a(7) = 20 °C (Juli),
sinusförmig glatt zwischen den Extrema.
(8.6) Zelltemperatur (NOCT-artig, Elevation-abhängig)
T_c(h, m) = T_a(m) + 30 · sin(α) [°C]
Begründung: bei hoher Sonne (sin α = 1) erreicht das Modul bis
zu 30 K über Umgebungstemperatur; bei flachem Sonnenstand fast
keine Erwärmung.
(8.7) Wirkungsgrad-Derate (linear oberhalb 25 °C STC)
η_temp(h, m) = 1 − 0.004 · max( 0, T_c(h, m) − 25 )
Penalty: −0,4 %/K über 25 °C. Beispiel: T_c = 50 °C → η = 0,90.
(8.8) Stunden-Shape vor Skalierung
shape(h, m) = irradianceShape(h) · η_temp(h, m)
(= 0 wenn Sonne nicht über Horizont, weil sin(α) ≤ 0.)
(8.9) Skalierung auf Tages-kWh aus dem Solar-Provider
shapeSum(m) = Σ_h shape(h, m)
dailyKwh(m) = monthlyKwh(m) / daysInMonth(m)
scale(m) = dailyKwh(m) · 1000 / shapeSum(m)
PV(h, m) = shape(h, m) · scale(m) [W]
(8.10) Inverter-Cap auf Stundenebene (Hard-Limit)
Wenn inverterLimitW gesetzt und PV(h, m) > inverterLimitW:
PV(h, m) := inverterLimitW
(Weitere DC/AC-Spitzen-Korrektur siehe §9 nach Multi-Array-
Summation.)
Annahmen & Vereinfachungen.
- Clear-sky-Form ohne Wetter-Modulation. Der Tagesschwerpunkt ist vereinfacht clear-sky-shaped; der absolute Tageswert kommt aus dem PVGIS-Monatsmittel und enthält dort bereits den Wetter-Anteil als Energie. An realen bewölkten Tagen wäre die Form flacher und breiter, im Monatsmittel mittelt sich das aus.
- Diffus/Direkt-Mix 25/75 in (8.4) ist eine PV-Sizing-Faustregel. Achtung Konzept-Verwechslung: §3 nutzt für Verschattung die landesspezifische
f_diffaus §0.5 (Anteil der Globalstrahlung, der diffus ankommt — DACH 0,50). §8 mischt 0,25 Diffus zu 0,75 Direkt — das ist der Modul-Sicht-Mix in der Tagesform-Schätzung (was das Modul aus Diffus-Strahlung „herausziehen” kann, vs. Direkt). Beide Werte sind konsistent in sich, modellieren aber verschiedene physikalische Größen. - Temperatur-Modell mitteleuropäisch parametrisiert. Für ES/IT überschätzt (8.5) den Sommer-Wirkungsgrad: reale Mittagstemperaturen in Sevilla erreichen 35–40 °C, im Modell stehen 20 °C. Tatsächliche T_c kann 60–65 °C erreichen → reale
η_temp ≈ 0,86, Modell rechnet mit0,92. - Sample im 1-h-Raster (Minute 30). Sub-Stunden-Sprünge in der Tagesform (z.B. dünne Wolken) sind nicht aufgelöst.
Wahl der Werte.
- Direkt/Diffus-Mix 0,75/0,25 in (8.4) ist die klassische PV-Sizing-Faustregel für Mitteleuropa. Alternative: dynamischer Mix nach Sonnenstand (clear-sky Index Kt → DNI/DHI-Split nach Erbs-Modell). Verworfen, weil das pro Stunde einen Wetter-Input bräuchte, den der Solar-Provider nicht liefert.
- NOCT-Faktor 30 K in (8.6) — Standard-NOCT-Bedingungen (800 W/m², 20 °C Umgebung, 1 m/s Wind) ergeben einen Modul-Temperatur-Anstieg von typisch 28–35 K. 30 K ist Mittelwert für glasbelegte Module ohne Hinterlüftung (Balkonkraftwerke häufig ohne Abstand zur Brüstung). Bei freistehender Aufständerung wäre 20 K realistischer; das würde den Sommer-Ertrag um ~1–2 % heben.
- Wirkungsgrad-Penalty 0,4 %/K in (8.7) — Standard für mono-c-Si-Module (Datenblatt-Pmpp-Temperaturkoeffizient ≈ −0,3 bis −0,5 %/K). Konservativ gewählt am oberen Ende der Spanne.
- T_a-Modell-Mittelwerte (10,5 °C, ±9,5 K Amplitude) decken DACH + Benelux + Nord-FR + PL/CZ gut ab. Für IT/ES/GR wären 17 °C ± 12 K realistischer (10 °C Winter / 29 °C Sommer); Refactor steht im Backlog.
- Stunden-Skalierung auf Tagesmittel statt Stunden-Daten — der Solar-Provider (PVGIS/PVWatts) liefert pro Stunde Energie-Mittel, die wir auf die clear-sky-Form skalieren. Alternative: direkter Bezug auf die Stunden-Daten des Providers (PVGIS hat das stündliche TMY). Verworfen, weil das die HTTP-Payload um Faktor 100 vergrößert und das Caching komplizierter macht — für die Eigenverbrauchs-Überlagerung in §6 spielt der Tagesschwerpunkt-Bias gegen die echten Stunden-Daten praktisch keine Rolle.
Genauigkeit.
- Tagessumme: exakt im Rahmen der Solar-Provider-Genauigkeit (siehe Sektion 11).
- Tagesform: plausibel für klare Tage, glatter als Realität bei wechselhaftem Wetter.
Wo darf man nicht trauen? Stündliche Vorhersagen für einen konkreten Tag — das ist eine Form-Schätzung, keine Wetterprognose.
Quelle.
Code: pv_hourly_estimator.dart.
Re-Kalibrierung.
- Mediterrane Sommer-Temperatur-Annahme bei Aufnahme von ES/IT/GR/PT als Default-Markt überarbeiten.
- 25/75-Diffus/Direkt-Ratio bei Bedarf landesabhängig machen (analog zu Sektion 3).
9. DC/AC-Clipping (Inverter-Limit)
Was rechnet die App? Modelliert den realistischen Verlust bei Balkonkraftwerken mit Wechselrichter-Limit (z.B. 800 W bei den deutschen 800-W-Anlagen, davor 600 W). Wenn die DC-Leistung der Module über dem AC-Limit liegt, “schneidet” der Wechselrichter die Spitze ab.
Eingaben. Stündliche Watt-Profile pro Modul-Array (Sektion 8), Inverter-Limit, Gesamt-Peak-DC.
Modell & Formel.
(9.1) Multi-Array-Summation pro Stunde
Total(h, m) = Σ_i PV_i(h, m)
mit PV_i = Stunden-Profil aus §8 pro Array i.
(9.2) DC/AC-Verhältnis
dcAcRatio = totalPeakWp / inverterLimitW
totalPeakWp = Σ_i ( panelCount_i · wattPerPanel_i )
(9.3) Extra-Clip-Fraktion (Heuristik für minütliche Über-Limit-Spitzen)
extraClipFraction = clamp( ( dcAcRatio − 1.2 ) · 0.10,
0, 0.20 )
(9.4) Anwendung des Limits (Hard-Cap + zusätzlicher Spitzen-Verlust)
Wenn Total(h, m) > inverterLimitW:
Total(h, m) := inverterLimitW · ( 1 − extraClipFraction )
Sonst:
Total(h, m) unverändert
Beispielrechnung — typisches Balkonkraftwerk-Setup DE.
2 × 1000 Wp Module + 800 W Inverter → dcAcRatio = 2000 / 800 = 2,5 → extraClipFraction = clamp((2,5 − 1,2) · 0,10, 0, 0,20) = clamp(0,13, 0, 0,20) = 0,13. Wenn Total(h, m) > 800 W, wird das Stunden-Mittel auf 800 · (1 − 0,13) = 696 W reduziert — die zusätzlichen 104 W repräsentieren minütliche Spitzen, die das Stundenmittel ohne den extra-Clip noch enthielte.
Bei einem 2 × 800 Wp Setup ist dcAcRatio = 1600 / 800 = 2,0 → extraClipFraction = 0,08, also 64 W zusätzlich abgezogen pro über-Limit-Stunde.
Bei 2 × 400 Wp / 800 W ist dcAcRatio = 1,0 < 1,2 → extraClipFraction = 0, keine zusätzliche Korrektur (es wird ohnehin nicht geclippt, weil Total < inverterLimit).
Annahmen & Vereinfachungen.
- Empirische Heuristik: ab
dcAcRatio ≥ 1,2wächst der Verlust durch minütliche Spitzen (die im Stundenmittel verschwinden) linear, bis maximal 20 % beidcAcRatio = 3. - Konstante
extraClipFractionpro Setup — real hängt der Effekt vom Wetterverlauf (Wolkendurchzug erzeugt Spikes) und der Inverter-MPPT-Logik ab. - Inverter-Wirkungsgrad selbst (98–96 %) steckt schon in der
defaultLossPercent = 18der Solar-Provider.
Wahl der Werte.
- Linearer Anstieg ab 1,2 mit Steigung 0,10 in (9.3). Empirische Kalibrierung gegen PVsyst-Vergleichsläufe (10 Setups DC/AC 1,0..3,0) und Field-Test-Daten von HM Solar und HTW Berlin. Schwelle 1,2 markiert den Punkt, ab dem die clear-sky-Stunden-Mittelung beginnt, minütliche Spitzen zu verlieren — darunter sind alle Spitzen vom Limit erfassbar.
- Cap bei 0,20 (20 %) verhindert, dass extreme Overpaneling-Setups (DC/AC > 3) unrealistisch viel Energie verlieren. Für DC/AC = 3 zeigt PVsyst typisch 18–22 % zusätzlichen Verlust — der Cap deckt das ab.
- Konstanter Faktor pro Setup statt wetter- oder MPPT-aware ist bewusst, weil der Solar-Provider sowieso nur Monatsmittel liefert. Ein dynamisches Modell mit Wolkendurchzug bräuchte stündliche Klar-/Bewölkt-Klassifizierung, die wir nicht haben.
Genauigkeit. ± 2 Prozentpunkte gegenüber minütlicher Simulation für typische DACH-Wettermuster und DC/AC < 2.5. ± 5 Prozentpunkte bei stark-überdimensionierten Setups (DC/AC > 2.5) oder mediterranen Klar-Wetter-Standorten.
Wo darf man nicht trauen?
- DC/AC > 3 (sehr selten bei Balkon, üblich bei Großanlagen).
- Inverter mit nicht-standardmäßiger MPPT-Logik (z.B. Hybrid mit Speicher-Bypass).
Quelle.
Code: yield_provider.dart, Funktion _sumAndCap. Empirische Kalibrierung: PVsyst-Vergleichsläufe und Field-Test-Daten von HM Solar und HTW Berlin.
Re-Kalibrierung.
Refactor-Vorschlag in docu/refactor-clipping.md: pro Stunde “Über-Limit-Hub” und Monatsstreuung berücksichtigen, statt konstanter Fraktion.
10. Solar-Daten-Routing
Was rechnet die App? Wahl des Solar-Daten-Providers anhand des Standorts.
Eingaben. Latitude, Longitude.
Modell & Formel.
(10.1) Bounding-Box-Routing (in dieser Reihenfolge ausgewertet)
Wenn 14 ≤ φ ≤ 72 und −170 ≤ longitude ≤ −52:
→ NLR PVWatts v8 (Nordamerika)
Sonst wenn −60 ≤ φ ≤ 70:
→ PVGIS v5_3 (EU/AF/MO/AS/AU/SA)
Sonst:
→ NASA POWER (Polar, Pazifik, sonst Fallback)
(10.2) Fehler-Fallback
Bei HTTP-Fehler oder Timeout des primären Providers:
→ einmaliger Retry mit NASA POWER
Bei Erfolg: Cache-Eintrag unter dem NASA-POWER-Schlüssel;
der primäre Provider bleibt ohne Cache-Eintrag und wird
beim nächsten Aufruf erneut zuerst probiert.
Bei Doppelfehler: Exception nach UI (PvgisOfflineException /
NetworkException, siehe §11).
Provider-Genauigkeit (Tabelle).
| Provider | Modell | Auflösung | Erwartete Abweichung |
|---|---|---|---|
| PVGIS v5_3 | SARAH-2 (Satellit) + ERA5 Reanalyse | 4 km, stündlich | ± 3 % Jahresertrag (vs. Pyranometer-Stationen) |
| PVWatts v8 | NSRDB TMY3 | 4 km, typisches meteorol. Jahr | ± 4 % Jahresertrag |
| NASA POWER | MERRA-2 Reanalyse | 0.5° × 0.625°, klimatol. Mittel | ± 5–10 % Jahresertrag |
Annahmen & Vereinfachungen.
- Bounding-Boxen sind grob — z.B. fängt die Karibik in der Nordamerika-Box, was OK ist (PVWatts deckt sie via NSRDB).
- Kein Retry-Loop: ein Fehler → ein Fallback. Beide kaputt → Exception nach UI.
Wahl der Werte.
- Nordamerika-Box
lat [14, 72], lon [−170, −52]umfasst US (inkl. Alaska), Kanada, Mexico und die Karibik. Untergrenze 14° schließt Süd-Mexiko ein (Tijuana → Yucatán). Westgrenze −170° fängt Hawaii + Aleuten; östliche Karibik fällt knapp in den Bereich. - PVGIS-Box
lat [−60, 70]ist die dokumentierte PVGIS-v5_3-Abdeckung: SARAH-2-Satellit erreicht 60°N/S, ERA5 ergänzt auf 70°N. Standorte nördlich von 70° (Spitzbergen, Nordgrönland) gehen automatisch auf NASA POWER. - NASA POWER als Last-Resort statt einer dritten regional spezialisierten API. Vorteile: globale Abdeckung, stabile API, kostenlos. Nachteil: gröbere räumliche Auflösung (0,5°×0,625° ≈ 55 km×70 km), klimatologisches Mittel statt typisches meteorologisches Jahr — daher ±5–10 % Abweichung.
Wo darf man nicht trauen?
- Mikroklima (Tal-Inversion, See-Effekt) wird in keinem Provider abgebildet.
- NASA POWER hat in komplexer Topographie (Alpen, Anden) systematische Bias.
Quelle.
Code: solar_data_router.dart, pvgis_data_source.dart, nrel_pvwatts_data_source.dart, nasa_power_data_source.dart.
Re-Kalibrierung.
- PVGIS-Endpoint auf nächste Major-Version pinnen, sobald JRC v5_4 veröffentlicht (Migration vermutlich 2027).
- NLR-Domain-Übergang ist abgeschlossen (siehe Doc-Comment in
app_constants.dart). - Bounding-Boxen prüfen, wenn neue Provider-API verfügbar wird (z.B. CAMS Solar Radiation, Solcast).
11. Yield-Orchestrierung (End-to-End-Datenfluss)
Was rechnet die App?
Bringt alle vorigen Bausteine in eine deterministische Pipeline und liefert die finale YieldComputation für den Results-Screen.
Datenfluss.
LocationConfig ──┐
PanelConfig ────┼─► SolarDataRouter ─► SolarDataProvider (Sektion 10)
ShadingMask ─────┘ │
▼
SolarDataRepository._applyShading (Sektion 3)
│
▼
Monats-kWh pro Array (bereits verschattet)
│
▼
PvHourlyEstimator (Sektion 8)
│
▼
Stündliches Watt-Profil pro Monat
│
_sumAndCap (DC/AC-Clipping, Sektion 9)
▼
Aggregiertes YieldResult
│
LoadProfileService (Sektion 4) ──┐ ▼
└─► SelfConsumptionService.estimateFromLoadProfile (Sektion 6)
│
▼
EconomicsService (Sektion 7)
│
▼
YieldComputation (UI-Bundle)
Aggregations-Formeln (Multi-Array).
(11.1) Verschattungs-Verlust pro Array (siehe §3)
L_i = (1 − f_diff) · L_direct_i + f_diff · L_diffuse_i [%]
mit i = 1..A (A = Anzahl Arrays).
(11.2) Globaler Verschattungs-Verlust (gewichtet nach DC-Peak)
L_global = ( Σ_i L_i · P_i ) / ( Σ_i P_i ) [%]
mit P_i = peakPowerKw_i.
Anzeige-Wert für Results-Screen, nicht berechnungsrelevant
(echter Verlust wirkt bereits pro Array in den Provider-Calls).
(11.3) Pro-Array-Provider-Call mit array-spezifischem Loss
result_i = solarProvider.getYield(
latitude, longitude,
peakPowerKw = P_i,
tilt = β_i, azimuth = γ_p_i,
shadingLossPercent = L_i
)
(11.4) Pre-Shading-Rückrechnung (für Wasserfall-Anzeige im Results)
Wenn result_i.preShagingKwh verfügbar:
kWh_pre_i = result_i.preShagingKwh
Sonst (Fallback, ältere Provider-Antworten):
kWh_pre_i = ( L_i > 0 )
? result_i.yearlyKwh / ( 1 − L_i / 100 )
: result_i.yearlyKwh
totalPreShagingKwh = Σ_i kWh_pre_i
(11.5) Stündliche PV-Summation + Inverter-Cap (siehe §9)
PV(h, m) = Σ_i PV_i(h, m)
PV_clipped(h, m) = apply_clip( PV(h, m), inverterLimitW,
dcAcRatio = totalPeakWp / inverterLimitW )
(11.6) Final-YieldResult-Aggregation
monthlyKwh(m) = ( Σ_h PV_clipped(h, m) / 1000 ) · daysInMonth(m)
yearlyKwh = Σ_m monthlyKwh(m)
(11.4) ist der Rückrechen-Pfad für ältere Provider-Antworten: PVGIS liefert das Pre-Shading-kWh direkt als outputs.totals.fixed.E_y, NASA POWER und ältere PVWatts-Antworten liefern nur den shading-adjustierten Wert. Wenn preShagingKwh = null, rechnen wir den Wert aus dem berechneten Verlust zurück — mathematisch äquivalent, solange der Provider tatsächlich L_i als Loss-Parameter verwendet hat (was er via shadingLossPercent-Argument tut).
Fehlerbehandlung.
- Solar-Provider-Fehler → einmalig NASA-POWER-Fallback (siehe §10 (10.2)), danach typisierte Exception nach oben (
PvgisOfflineException,NetworkException,CacheCorruptException— siehe Refactor-Plan). - Cache-Korruption führt zu Re-Fetch.
- Validierung:
LocationConfigundEconomicsInputMÜSSEN gesetzt sein; sonst gibtyieldProvidernullzurück (Results-Screen zeigt leeren Body, kein Crash).
Wo darf man nicht trauen?
- Provider-Cache hat aktuell kein TTL — wer manuelle Anpassungen am Standort macht, sollte einen “Daten neu laden”-Button bekommen (Backlog).
Quelle.
Code: yield_provider.dart.
Re-Kalibrierung.
- Bei Speicher-Phase-2: weiterer Pipeline-Block zwischen
estimateFromLoadProfileundEconomicsService.
12. Telemetrie & Privatsphäre
Was rechnet die App? Die App trennt zwei Pfade sauber. (1) Diagnose-Telemetrie: App-Start, Konfigurationsänderungen, Yield-Berechnungen, HTTP-Provider-Fehler und Recoverable Degradations (z.B. NASA-POWER-Fallback) werden rein lokal protokolliert — kein Sentry, kein Crashlytics. Crash-Reports kommen ausschließlich über die Built-in-Pfade Apple (TestFlight / App-Store-Connect → Crashes) und Google (Play Console → Android Vitals). (2) Anonyme Nutzungsstatistik: ein separater, Opt-in-gegateter Funnel-Analytics-Kanal über TelemetryDeck (EU-Server), standardmäßig aus — Details im eigenen Absatz unten.
Drei lokale Sinks (Auto-Routing über Logger.root.onRecord):
- Dev-Console —
dart:developer.log, nur im Debug-Build. Sichtbar in Xcode-Console / Flutter-DevTools. - In-Memory-Ring —
TelemetryService.recentEvents, Cap 300 Events. Quelle für den sofortigen Diagnose-Export. - Persistent-Rolling — Hive-Box
diagnostic_log, Cap ≈ 400 KB ≈ 2500 Events. Überlebt App-Restart, ist Quelle für Pre-Crash-Forensik im User-eingereichten Export.
Stadt-Level-Anonymisierung an der Quelle.
Was ein Aufrufer roh übergibt (Lat/Lon mit 7 Nachkommastellen, displayName, postcode, city), landet in keinem Sink in dieser Form: der Anonymizer läuft direkt in TelemetryService.record(), sodass In-Memory-Ring und Persistent-Sink nie feinere als ~11-km-Koordinaten sehen. Insgesamt greifen drei Sanitization-Schichten:
TelemetryAnonymizer(greift bereits beim Schreiben inTelemetryService.record(), damit implizit auch für jeden Export, siehetelemetry_anonymizer.dart):- Lat/Lon rekursiv auf 1 Nachkommastelle gerundet (
(value * 10).round() / 10), entspricht ≈ 11 km Stadt-Level-Granularität. displayName,postcode,cityaus jedem Objekt entfernt.countryCodebleibt (für Country-Default-Kontext).- Greift auf
location,locationAtCapture, nestedconfig.locationund Array-Container.
- Lat/Lon rekursiv auf 1 Nachkommastelle gerundet (
LogSanitizer(Pflicht vor jedem Sink-Eintrag): ersetzt iOS-Sandbox-Pfade/var/mobile/Containers/[uuid]/[uuid]/[uuid]/…in Stack-Traces durch den Platzhalter[app-sandbox]/. Verhindert Pseudonym-Leaks im persistierten Buffer.- HTTP-URL-Bereinigung (in
BaseSolarDataSource): Log-Felder enthalten nur den Endpunkt-Pfad (/api/v5_3/PVcalc), nicht?lat=…&lon=…. Koordinaten kommen ausschließlich als gerundete Event-Felder (lat1,lon1) zurück.
Was die App im Teilen-Dialog verspricht (settingsDiagnoseShareDialogBody, alle 8 Locales): Stadt-/Regionsebene gerundet (etwa 10 km), Adresse + Postleitzahl + exakte Koordinaten verlassen das Gerät nicht, keine Fotos, kein Konto. Dieser Text MUSS mit dem Code-Verhalten oben übereinstimmen — siehe die Tests in telemetry_anonymizer_test.dart, die das Versprechen mit Fixtures verankern.
Pflicht-Event-Kategorien (für Cluster-fähige Diagnose-Exports): solar_request_fail, solar_fallback_triggered, solar_parse_fail, solar_compute_exception, hive_read_error, camera_init_fail, permission_denied, iap_fail. Schema + Pflichtfelder in docu/logging-conventions.md.
Anonyme Nutzungsstatistik (TelemetryDeck, Opt-in).
Neben der lokalen Diagnose-Telemetrie existiert genau ein Off-Device-Sink: ein Funnel-Analytics-Kanal über TelemetryDeck (managed, EU-Server, anonym). Regeln (verankert in funnel_analytics_service.dart):
- Opt-in, Default aus. Das SDK wird erst nach expliziter Einwilligung gestartet (doppelt gegated: lazy SDK-Start + Laufzeit-Guard vor jedem Send); der Bootstrap-Default ist ein No-Op ohne Off-Device-Pfad. Jederzeit in den Settings abschaltbar. Hintergrund der strikten Gates: TelemetryDeck legt eine gehashte Install-ID an (TTDSG §25).
- Keine Koordinaten, keine Config-Werte, kein PII. Zahlen verlassen das Gerät nur als Ordnungszahl (Wizard-Step-Index) oder Größenklassen-Bucket.
- Genau 11 Signale. Flow-Funnel:
onboarding_started,wizard_step_completed,hero_reached,recommendation_shown,recommendation_tapped. Monetarisierung:paywall_shown(mit Gate-Name),premium_purchased,purchase_restored,pdf_exported(summary/detail),config_slot_created(nur Größenklasse 1 / 2 / 3–5 / 6+),flow_restarted.
Der In-App-Methodik-Screen (methodologySection12Body) und das Analytics-Info-Sheet (analyticsInfoSheetBody) beschreiben genau diesen Stand gegenüber dem User.
Wo darf man nicht trauen?
- Im Debug-Build läuft zusätzlich der Dev-Console-Sink: dann landen ROHE Koordinaten in der Xcode-Konsole. Das ist Entwickler-only, niemals User-sichtbar.
- Der Persistent-Rolling-Buffer überlebt App-Restart. Da der Anonymizer seit der Verlagerung in die Schreib-Pipeline (
TelemetryService.record()) an der Quelle greift, liegen dort nur Stadt-Level-anonymisierte Events — ein kompromittiertes Gerät gibt keine rohen Koordinaten preis. (Der frühere Backlog-Punkt „Anonymizer in die Schreib-Pipeline ziehen”, Stand 2026-05-23, ist umgesetzt.)
Re-Kalibrierung. Jede Erweiterung der Cloud-Nutzungsstatistik (neue Signale, neue Felder) MUSS:
- Opt-in mit deutlicher Erklärung bleiben (Default aus).
- Die PII-Regeln oben einhalten (keine Koordinaten, keine Config-Werte, nur Ordnungszahlen/Buckets).
- Vor Rollout in dieser Sektion + im Methodik-Mirror (
horisol.byteballoonapps.com/docs/methodik) beschrieben werden — die Signal-Liste hier undfunnel_analytics_service.dartsind synchron zu halten.
Code.
telemetry_service.dart— Schreib-Pipeline + Ring-Buffer (Anonymizer an der Quelle).telemetry_anonymizer.dart— PII-Sanitization (Schreiben + Export).funnel_analytics_service.dart— TelemetryDeck-Funnel (Opt-in, 11 Signale).log_sanitizer.dart— Stack-Trace-PII-Filter.diagnostic_log_box.dart— Persistent-Rolling-Hive-Sink.app_logger.dart— Logger-Fassade + Root-Setup.
13. Bekannte Limits & Backlog
Diese Punkte sind explizit als nicht-Showstopper für TestFlight markiert — sie gehören in die nächste Iteration und werden hier transparent geführt, damit User wissen, was die App heute noch nicht kann.
Anlagen-Vorschlag: Tilt-Default = 60° (geändert 2026-06-27).
Der BalconyRecommendationService.kDefaultTiltDeg schlägt 60° statt der früheren 30° vor (synchron: PanelSetup.defaults()). Begründung: Balkon-Geländermontage hängt das Modul außen am Geländer, real über Winkel-Halterungen meist um die 60° — deutlich steiler als eine Dach-Aufständerung. 30° war ein zu flacher, schöngerechneter Startwert (höherer Jahresertrag als realistisch). 60° ist konservativer/ehrlicher und konsistent mit dem Anti-Hype-Versprechen. Reine Vorschlags-Heuristik, kein Modell-Eingriff — das Ertragsmodell (§3, §8) rechnet mit dem tatsächlich bestätigten Tilt; der HTW-Crosscheck-Referenz-PV bleibt explizit bei 30° (Süd/30°, §4.2-Anker, unverändert). Backlog: geometrie-/breitengrad-abgeleitete Tilt-Verfeinerung (heute fixer Default).
Segment-Ranking: entfernt (Single-Flow Phase D/G, 2026-07-03).
Der frühere Geländer-zentrierte Guided-Flow erfasste pro Balkon-Seite ein RailingSegment und wählte die ertragsstärkste Seite über einen relativen Ertrags-Proxy (annualDirectPoaWeight-Verhältnis, historische Herleitung siehe plans/archive/quick-extended-flow.md und Git-Historie dieser Datei). Mit dem Single-Flow-Redesign (plans/archive/single-flow-redesign-on-dev.md) ist die Geländer-Erfassung samt Ranking komplett gefallen: die Anlagen-Ausrichtung bestimmt der User direkt im Panel-Step (Drag-Ring oder Kompass-Erfassung; kein stiller Süd-Fallback — azimuthConfirmed-Gate) und BalconyRecommendationService.recommend() reicht sie nur noch durch (currentAzimuthDeg, Quelle bestSegment = „aus deiner Ausrichtung”, speist die Results-Provenienz-Card). Der frühere eigenständige Ausrichtungs-Step (Live-Kompass/8-Richtungs-Wahl mit Skip = Süd) ist mit entfernt. Die Ranking-Konstanten kShadedThresholdPercent und kDefaultDiffuseFraction sind mit dem Code gelöscht; kDefaultTiltDeg = 60° (oben) und kStandardModuleWidthM = 1,13 m (Breiten-Hinweis im Panel-Step) bleiben. Alte Hive-Slots mit railingSegments laden tolerant (Feld wird ignoriert, §14).
Auto-Horizont-Erkennung: getunte Bildheuristik-Schwellen (Stand 2026-07-04).
Der HorizonDetectionService (Sobel-Y-Kantenerkennung im compute()-Isolate) schlägt beim Verschattungs-Capture eine Horizontlinie vor, die der Nutzer im MarkerStep bestätigt oder korrigiert — er speist das Ertrags-/Verschattungsmodell (§3) nicht direkt und ist deshalb keine Modell-Konstante im Sinne der 8-Punkte-Schablone. Die Schwellen sind trotzdem ehrlichkeitsrelevant („lieber null als geratene Linie”) und werden hier geführt: minColumnContrast = 0.10 (initial 0.14, gesenkt 2026-07-04 nach Gerätetest-Befund „erkennt zu wenig”), minConfidentColumnRatio = 0.22 (initial 0.30), strongEdgeRatio = 0.6 (neu: topmost-edge-Bias — oberste hinreichend starke Kante statt absolut stärkster, damit Dachkanten gegen kontrastreiche Fassaden-Kanten gewinnen). Re-Tuning nur gegen echte Capture-Fotos (Spike-Test HORIZON_SPIKE_DIR), Herleitung im dartdoc von horizon_detection_service.dart.
Lastprofil-Lokalisierung. Aktuell fällt jedes Land außerhalb DE auf BDEW H25 zurück. Verfügbare öffentliche Standardlastprofile zur späteren Integration:
| Land | Quelle | Format | Status |
|---|---|---|---|
| AT | APCS / E-Control “Synthetische Lastprofile H0” | Excel, viertelstündlich | recherchierbar |
| CH | VSE “Standard-Lastprofile H1/H2/H3” | PDF + Excel | recherchierbar |
| FR | Enedis “Profils de soutirage RES1” | CSV, halbstündlich | offen verfügbar |
| NL | NEDU “Verbruiksprofielen E1A” | Excel | offen verfügbar |
| BE | Synergrid “SLP S21” | Excel | offen verfügbar |
| IT | ARERA “Profili tipo bassa tensione” | recherchierbar | |
| ES | REE “Perfiles de consumo Tipo A” | CSV | offen verfügbar |
| PL | PTPiREE / PSE “Standardowe profile zużycia G11” | recherchierbar | |
| CZ | OTE “Typové diagramy dodávky D02” | Excel | offen verfügbar |
Modul-Technologie-Wahl (verworfen, 2026-05-23).
Aktuell hardgecodeter pvtechchoice=crystSi in PVGIS, module_type=0 in PVWatts. Ursprünglich als Premium-Hook geplant, jetzt verworfen. Begründung: Der User gibt peakPowerKw direkt ein, damit ist der Wirkungsgrad nur noch flächenrelevant. Die verbleibenden Sekundäreffekte (Temperatur-Koeffizient: c-Si −0,3 bis −0,5 %/K vs. CdTe ≈ −0,25 %/K, Spektral-/Low-Light-Response) liefern für DACH-Setups < 1 % Yield-Delta, für Mittelmeer 2–3 % — beides unter unserer Verschattungs-Mess-Unsicherheit (~±5 %) und der nominalen Wp-Toleranz der Module (±3 %). Ein Premium-Selector wäre ein Präzisions-Placebo und würde dem Anti-Hype-Versprechen widersprechen. Bleibt fixiert auf c-Si.
Bifazial-Modul + Albedo (Premium-Hook). Die einzige Modul-Tech-Wahl mit messbarem Yield-Impact: 5–15 % Jahresertrag bei Aufdach/Flachdach mit Abstand zur Dachfläche und hellem Untergrund (weißes Dach / Schnee → Albedo 0,6–0,8). Bei typischen Balkon-Setups (Brüstung dahinter, Albedo ≈ 0,05, kein Mounting-Gap) praktisch egal — die Premium-UX muss das ehrlich kommunizieren statt zu suggerieren, dass Bifazial universell lohnt.
Geplantes Modell als Post-Multiplikator auf den Provider-Output (PVGIS hat kein Bifacial-Flag; PVWatts v8 hat einen, aber wir behandeln beide Provider gleich):
yieldBifacial = yieldProvider × (1 + bifacialityFactor × expectedAlbedo × mountingGapFactor)
| Größe | Quelle | Default / Spannweite |
|---|---|---|
bifacialityFactor | Modul-Datenblatt | 0,75 (typisch 0,70–0,90) |
expectedAlbedo | Anlagentyp + User-Pick | Balkon-Brüstung 0,05 · Asphalt 0,10 · Beton/Gravel 0,20 · weißes Dach 0,60–0,80 |
mountingGapFactor | Aufständerungs-Geometrie | 0 (flach auf Brüstung) · 0,3 (10 cm Abstand) · 1,0 (Hochkant-Aufständerung) |
UX-Hook: siehe ux-flow.md §4.8.
Speichersimulation + Panel-Alternative-Vergleich.
Phase 2 (Sprint S, voller Plan in plans/battery-storage-plan.md). Speicher würde Eigenverbrauch um typisch +30 Prozentpunkte heben und einen separaten Modellpfad brauchen (Greedy-Sim mit separater η_charge/η_discharge, Min/Max-SoC, Kalender-Alterung). Parallel dazu eine Panel-Alternative-Rechnung: virtuelles zweites Panel mit PanelAlternative(azimuthDeg=270, tiltDeg=<current>, peakWattsW=<current>) und 800-W-Inverter-Cap, damit User vergleichen können, ob bei DACH-Balkon-PV das zusätzliche West-Panel wirtschaftlicher ist als der erste Speicher-Euro. UI-Sichtbarmachung (Hero-Tile „Geht da noch was?”) ist Phase 1 und seit Sprint Hero-Tile (2026-05-24) im Results-Screen live. Volle Modell-Sektion wird in Sprint S5 hier als §15 ergänzt (FAQ wird dann zu §16).
Strompreis-Inflation. Bewusste Year-1-Vereinfachung. Premium-Option, sobald die Wirtschaftlichkeit-UI ausreichend reift.
Wechselwirkung Anwesenheit × Wärmepumpe im Lastprofil. Aktuell additiv-unkorreliert; in Realität korreliert. Verbesserung erfordert ein Wahrscheinlichkeits-Modell statt additiver Profile.
DC/AC-Clipping intra-day.
Konstante extraClipFraction pro Setup. Refactor-Vorschlag in docu/refactor-clipping.md (in Arbeit).
Mikroklima. Kein Provider modelliert Tal-Inversion, See-Effekt, urbane Wärmeinsel. Realistische Erwartung für PV-Apps; nur erwähnt, damit der User nicht überrascht ist, wenn Nachbarschaftsanlagen abweichen.
14. Daten-Invalidierungs-Regeln (Wizard-State)
Was rechnet die App? Nichts Mathematisches — aber operativ entscheidend für „ehrliche Anzeige”: Wann sind erfasste Daten so weit von der aktuellen Eingabe-Realität entfernt, dass sie nicht mehr in die Berechnung einfließen dürfen? Diese Sektion bricht bewusst aus der 8-Punkte-Schablone aus, weil sie keine Berechnungs-Annahme dokumentiert, sondern die Regeln, wann das Modell überhaupt mit welchen Inputs rechnet.
Soll-Matrix.
| Auslöser | Was wird invalidiert? | Wie? |
|---|---|---|
| Standort > ~100 m verschoben | Solar-Daten-Cache (PVGIS/NLR/NASA) | Automatisch via Lat/Lon-3-Dezimalstellen-Cache-Key. Neuer Cache-Eintrag, alter bleibt liegen. |
| Standort > 50 km verschoben | Hinweis-Banner zur Verschattung | Soft-Warning im LocationScreen. User entscheidet bewusst über Re-Capture — Anti-Hype: nichts heimlich löschen. |
| Panel-Azimut geändert | Shading-Captures des betroffenen Arrays | Vergleich beim Verlassen des Panel-Screens (und beim Öffnen des Shading-Edit aus dem Results-Sheet) via isShadingInvalidated. Dialog mit Optionen „Behalten / Neu erfassen / Abbrechen”. Selektiv: andere Arrays bleiben. |
| Panel-Tilt geändert | Shading-Captures des betroffenen Arrays | Gleiche Dialog-Mechanik wie Azimut. Tilt-Drift macht den Sun-Path-Overlay-Bezug der gezeichneten Horizont-Linie inkonsistent. Snapshot in panelTiltsAtCapture. |
| Array hinzugefügt | Shading-Plan neu zu planen | isShadingInvalidated erkennt Längendifferenz → Recapture-Dialog. |
| Array entfernt | Captures dieses Arrays | Partition via partitionCapturesByArrayChange — Fotos werden vom Disk gelöscht. |
| „Neu fotografieren” (im Marker-Step) | horizonY der Capture | Foto + Linie werden hart entfernt, Capture-Slot auf null. ValueKey auf MarkerStep erzwingt frisches Widget-Mount. |
| „Linie weg” (Reset-Button) | horizonY der Capture | Modell-Wert wird auf null gesetzt (nicht auf eine all-1.0-Liste — semantisch konsistent mit „nie gezeichnet”). |
Consumption-Mode-Wechsel zu fast | Kein State-Reset. LoadProfile.applyModeSwitch setzt nur das mode-Flag; alle Detail-Felder bleiben persistiert. | Non-destruktiv, compute-zeitlich: LoadProfileService._effectiveProfileForMode ersetzt im Fast-Modus die nur in detail sichtbaren Felder (shiftableDevices, punctualDevices, shiftableSchedule, EV-km, WP-Verbrauch, WP-SCOP, Duschende sowie das Klimagerät ac→none/acAnnualKwh) durch den Haushaltstyp-Default — nur für die Rechnung. So formen sie weder Lastkurve noch Wirtschaftlichkeit, obwohl der User sie im Fast-Modus weder sieht noch ändert. Der persistierte State behält sie: detail → fast → detail ist verlustfrei (brachliegende Felder kehren intakt zurück). Nicht substituiert: presenceWindows, householdAnnualKwh, household, ev, heatPump, instantWaterHeater und alle *AlreadyIncluded-Flags — diese zeigt und editiert der Fast-Modus selbst (Anwesenheits- + Big-Consumers-Karte). Ausnahme ac: anders als die übrigen Großverbraucher-Enums ist das Klimagerät Detail-only und wird im Fast-Branch substituiert (oben), weil die AC-Eingabeseite im Fast-Modus gar nicht erscheint. Damit weder stiller Daten-Use (Fast-Kurve unkontaminiert) noch stiller Daten-Verlust. Plan: plans/archive/consumption-mode-nondestructive-compute.md. |
| Klimagerät geändert (Detail) | Eigenverbrauchsquote → Results | Riverpod-Kaskade über configProvider → yieldProvider. Kein expliziter Cleanup nötig. ac/acUsagePattern/acAnnualKwh/acAlreadyIncluded sind Detail-only und im Fast-Modus rechnerisch inaktiv (siehe Zeile darüber); persistiert bleibt alles, detail → fast → detail ist verlustfrei. Siehe §4.10. |
| Haushaltsgröße gewechselt (Consumption) | Kein State-Reset. LoadProfile.applyHouseholdSwitch ändert nur household + den Jahresverbrauch-Vorschlag householdAnnualKwh; Modus + alle Detail-Felder (Geräte, Anwesenheit, Großverbraucher, *AlreadyIncluded-Flags, shiftableSchedule) bleiben persistiert. | Non-destruktiv, analog applyModeSwitch. Ersetzt das frühere defaultFor(type).copyWith(...) in ConsumptionScreen.changeHousehold, das den Modus auf fast und alle Detail-Eingaben zurückwarf (Bug 2, 2026-07-19). householdAnnualKwh wird nur überschrieben, wenn der aktuelle Wert noch ein erkannter Default (isHouseholdAnnualDefault) oder null ist — ein selbst eingegebener Rechnungswert bleibt erhalten. Der geschriebene Preset ist der reine Haushaltswert und ist literal autoritativ; als „enthalten” markierte Großverbraucher stecken innerhalb dieses Werts (kein Auto-Wachstum, §4.2). Verankert über test/unit/consumption_mode_switch_test.dart (Gruppe „applyHouseholdSwitch”) + test/widget/consumption_merged_screen_test.dart (Bug-2-Tests). |
Alt-Hive-Slot mit baseLoadDevices-Key | Tolerant ignoriert. Die Grundlast-Geräte (Kühlschrank/Router/Standby/NAS) sind 2026-07-19 entfallen (in H25 implizit enthalten, nur energie-neutrale Abflachung — Bug 3). | LoadProfile.fromJson liest den alten Key nicht mehr (kein as List-Zwang), Bestandsprofile laden ohne Crash und verlieren das Feld beim nächsten Save. Präzedenz: railingSegments (§14, Balkon-Geländer-Wegfall). Kein Eigenverbrauchs-Regress: Default-/Demo-Profile nutzten baseLoadDevices ohnehin nicht; der HTW-Crosscheck bleibt innerhalb ±2 Pp (self_consumption_htw_crosscheck_test.dart). |
| Consumption-Eingabe geändert | Eigenverbrauchsquote → Results | Riverpod-Kaskade über configProvider → yieldProvider. Kein expliziter Cleanup nötig. |
| Economics-Eingabe geändert | Amortisation, Year-1-Ersparnis | Riverpod-Kaskade. |
| Settings: „Anlage zurücksetzen” | PanelSetup → Defaults, additionalPanelArrays, shadingCaptures (inkl. Foto-Dateien auf Disk), panelArrayAzimuthsAtCapture, panelTiltsAtCapture, locationAtCapture, consumptionProfile → Defaults, loadProfile (regeneriert), economicsInput → null, lastStepRoute → Routes.location. location bleibt erhalten. | ConfigNotifier.resetKeepingLocation() baut HoriSolConfig.empty() mit kept-location, ruft configRepository.save() + shadingImageStore.clearActive() (nicht clearAll() — Slots bleiben), navigiert via context.go(Routes.location). Begründung: „Anlage” meint das physische Setup; der Standort ist Meta-Information. Mental-Model „neue Anlage am selben Ort”. lastStepRoute wird explizit auf Location gesetzt, damit „Weitermachen” nach dem Reset wieder dort startet (sonst würde die Fallback-Heuristik wegen gesetzter Location fälschlich nach Economics springen). |
| Wizard-Step im linearen Durchlauf betreten (kein Edit-Mode) | lastStepRoute → Route des betretenen Steps, nur wenn höher als der bisherige Marker (max-monoton) | ConfigNotifier.markStepReached(route) via Mixin WizardStepProgress (PostFrame im initState jedes Step-Screens, editMode-Guard). Rücksprung senkt den Marker nicht → ein Resume landet nie zu früh. Kein Schreiben im Edit-Mode (Modal-Sheets vom Results-Screen). Treibt routeForConfig („Weitermachen” + Slot-Load). Begründung: ehrliches „weiter wo du warst” statt Sprung zum nächsten leeren Pflichtfeld mit Default-Werten. |
Wizard-Input geändert (via ConfigNotifier.update()) während Results-Screen nicht sichtbar (kein aktiver Listener auf yieldProvider) | Gecachter yieldProvider-Compute-Stand | yieldProvider ist als FutureProvider.autoDispose<YieldComputation?> definiert (ohne keepAlive). Sobald der letzte Listener (Results-Screen, Shading-Review-Step) den Provider verlässt, wird er disposed; der nächste ref.watch startet einen frischen Compute, der die aktuelle configProvider-Mutation sieht. Kein ref.invalidate(yieldProvider) in ConfigNotifier.update() — wäre ein direkter Cycle (Notifier mutiert State UND fasst eigenen transitiven Dependent an) und wirft CircularDependencyError. Der SolarDataRepository-Hive-Cache absorbiert den teuren HTTP-Call, sodass der Re-Compute beim Re-Mount nur lokale Math (<100 ms) macht. Verankert über test/integration/wizard_recomputation_test.dart (Plan: docu/plans/archive/wizard-recomputation-after-back.md). |
| Ausrichtung im Panel-Step bestätigt | azimuthDegrees und azimuthConfirmed = true werden auf der aktiven Gruppe gesetzt (Drag-Ring oder Kompass-Erfassung): Primärgruppe → panelSetup, Zusatzgruppe → additionalPanelArrays[i]. Keine atomare Snapshot-Mitführung an dieser Stelle. | Der eigenständige Orientation-Step ist gefallen — die Richtung wird direkt im Panel-Step gesetzt. azimuthConfirmed ist ein reines UI-Gate-Flag (kein Rechen-Input) und existiert seit 2026-07-18 (Geräte-Test-Befund: unbestätigte Zusatzgruppe rutschte still in die Verschattung) pro Gruppe: PanelSetup.azimuthConfirmed startet false; PanelArray.azimuthConfirmed hat Default true (Quellkompatibilität aller Bestands-Konstruktionsstellen), nur der addGroup-Klon setzt explizit false — ein Klon erbt die Richtung, aber nie die Bestätigung. Das Gate sperrt „Weiter”, bis alle Gruppen bestätigt sind (Umkehr der früheren „Gate nur Primärgruppe”-Entscheidung aus plans/ipad-layout-fixes.md); zusätzlich sperrt es die „+“-Pill (keine neuen Klone auf unbestätigte Gruppen stapeln). Der Nudge-Hint benennt die fehlende Gruppe (panelAzimuthNudgeHintGroup). Edit-Mode bypasst das Gate weiterhin; eine im Edit-Mode angelegte Gruppe bleibt unbestätigt und gated einen späteren Wizard-Lauf — korrekt, ihre Richtung wurde nie aktiv gesetzt. Legacy-JSON ohne Key lädt als bestätigt (?? true, kein retroaktives Sperren); toJson schreibt den Key nur bei false (Bestands-JSON bleibt byte-identisch). Der Panel-Step führt den Shading-Snapshot nicht pro Drag mit; im Vorwärts-Flow (panel → shading) existieren beim Setzen noch keine Captures, und im Edit-Pfad greift die bestehende Recapture-Dialog-Logik in _handlePanelConfigDone (isShadingInvalidated) zur Navigationszeit. Verankert über test/unit/panel_setup_test.dart (Gruppe „azimuthConfirmed”), test/unit/panel_array_test.dart und test/widget/panel_config_azimuth_gate_test.dart. |
| Anlagen-Vorschlag angewandt (Empfehlungs-Slot) | panelArrayAzimuthsAtCapture + panelTiltsAtCapture werden atomar mit panelSetup aktualisiert → kein False-Positive der Shading-Invalidierung | BalconyRecommendationService.recommend() liefert einen trivialen Vorschlag mit Provenienz (Azimut durchgereicht, Modulzahl/Watt aus dem Länder-Default, Tilt = kDefaultTiltDeg) und speist seit dem Orientation-Wegfall nur noch die Provenienz-Card im Results-Screen. applyRecommendation(config, recommendation) bleibt als getestete Utility erhalten (setzt im selben copyWith die Capture-Snapshots atomar mit, gegen das Recapture-Paradox), wird aber vom Panel-Step nicht mehr aufgerufen — der Panel-Prefill setzt nur noch die Länder-Balkon-Defaults (Typ/Anzahl/Watt/Inverter-Limit), nicht den Azimut. Verankert über test/unit/balcony_recommendation_service_test.dart (Gruppe „Snapshot-Paradox-Fix”). |
| Amortisations-Kachel (Results, read-only) | Nichts. Die Amortisationszeit ist eine reine Anzeige-Kachel — kein Eingabefeld, kein lokaler Widget-State, kein Hive-Write, daher keine State-Cleanup-Stelle. | Der Wert wird flüchtig über die öffentliche EconomicsService.amortizationYearsFor aus der bereits angezeigten yearlySavings gerechnet (kann von der gezeigten Ersparnis nicht abweichen). Seed = persistierter economicsInput.investmentCost (im Economics-Step gesetzt), bei <= 0 die Default-Heuristik defaultInvestmentCost(panelCount, wattPerPanel) — nie ein Phantom-„0 Jahre”. Jenseits ~30 a zeigt die Kachel ehrlich resultsAmortizationNone („keine Amortisation in Lebensdauer”) statt einer Zahl. Der frühere editierbare InteractivePaybackBlock (Single-Flow Phase G) ist mit dem Phase-F/G-Teilrevert entfallen (3-Step-Flow zurück + Amortisation read-only, 78cd33c/00bfd67); der Anschaffungspreis wird jetzt ausschließlich im Economics-Step persistent editiert (Zeile „Economics-Eingabe geändert” oben). Verankert über test/widget/results_screen_test.dart. |
Was wird nicht automatisch invalidiert?
- Standort-Drift zwischen 100 m und 50 km. Bewusst stumm — der Solar-Cache-Key reicht; die App rechnet einfach mit neuen Daten.
- Cross-Effekt „Location-Wechsel → Shading-Captures veraltet”. Heute nur Banner-Warnung. Automatisches Hard-Clear wäre paternalistisch; der User hat im Stadt-Umzugsfall meist Recht, das alte Shading zu behalten.
- Backwärts-Nachgehen im Wizard ohne Inhalts-Änderung. Wenn der User nur „zurück” tippt um nachzusehen, bleibt alles unverändert.
balconyDefault.peakLimitW-Cap überschritten (z.B. nach Land-Wechsel, IAP-Ablauf, Slot-Load aus Premium-Stand): die Konfiguration bleibt wie sie ist, der User sieht den Inline-Hinweis und entscheidet selbst, ob er Module entfernt. Kein heimliches Clamp auf den Cap — Anti-Hype-Compliance, gleicher Grundsatz wie beiclampInverterLimitForFree(das nur beim expliziten Slot-Load greift, sichtbar via Snackbar, nicht im Hintergrund).PlantType.rooftop-Wechsel verliert Premium-Status (Refund, IAP-Ablauf): Config bleibt unverändert, derPlantTypeToggleist beim nächsten Wizard-Eintritt für Aufdach gelockt — der User kann zu Balkon zurückwechseln, aber nicht heimlich umgekehrt. Symmetrisches Pendant: kein Auto-Reset auf Balkon, kein Auto-Lösen der Zusatz-Arrays.- Alt-Slots mit Geländer-Feldern (
railingSegments,balconyGeometry,facingAzimuthDeg): werden beim Laden tolerant ignoriert, nie invalidiert und nie zum Crash. Die Geländer-Erfassung ist mit dem Single-Flow (Phase D/G) komplett gefallen;HoriSolConfig.fromJsonüberliest einen vorhandenenrailingSegments-Key und die V1/V2-Legacy-Geometrie,toJsonschreibt sie nicht mehr (der Slot verliert die Felder beim nächsten Save — bewusst, sie speisen nichts mehr). NurrailingHeightCmlebt als Feld weiter (Roundtrip-stabil, aktuell ohne Leser). Verankert übertest/unit/horisol_config_legacy_slots_test.dart.
Wizard-Reihenfolge (Single-Flow).
Seit dem Single-Flow-Redesign (Phase A, plans/archive/single-flow-redesign-on-dev.md) gibt es genau eine lineare Reihenfolge (singleWizardOrder in wizard_routing.dart):
welcome → location → panel → shading → consumptionMode → consumption → economics → results (8 Schritte). Der frühere Quick/Guided/Experten-Split ist aufgelöst; wizardOrderFor(config) ignoriert HoriSolConfig.guidedEntryPath (Feld eingefroren, Default true, nur noch JSON-Backward-Compat — nicht als Steuerflag verwenden). Der frühere eigenständige orientation-Step (davor railing) ist gefallen — die Anlagen-Ausrichtung wird direkt im Panel-Step gesetzt (Drag-Ring + Kompass-Erfassung, Gate über azimuthConfirmed). Zum Verbrauchs-Flow: Der Phase-F-Merge (ein gemeinsamer Verbrauch+Economics-Screen) wurde mit dem 3-Step-Revert (78cd33c) zurückgenommen — consumptionMode (schnell-vs-detailliert-Wahl), consumption (Verbrauchs-Shaping) und economics (Strompreis, Anschaffungskosten, Einspeisung) sind wieder drei eigenständige, aktive Steps mit je eigenem Screen.
Konsequenzen für die State-Machine: Der Fortschritts-Marker (lastStepRoute) und die Max-Monotonie (markStepReached) leben im Index-Raum dieser Order. Alt-Marker-Resume: persistierte Slots mit Markern außerhalb der Order werden von _remapObsoleteGuidedMarker auf den Step remappt, der ihre Spine-Position übernommen hat — /facing, /balcony-geometry, /railing, /orientation → panel — statt auf die grobe Legacy-Heuristik zu fallen; _capForPrereqs floort weiter auf location, falls Pflichtdaten fehlen. consumptionMode und economics sind wieder aktive Steps und brauchen kein Remap mehr. Die Routen-Konstanten Routes.railing/Routes.orientation bleiben als tote-aber-gehaltene Konstanten im Code (nur ihre Screens + GoRoutes sind gefallen); Routes.consumptionMode/Routes.economics sind dagegen wieder voll bespielte Routen mit Screen. Quick lag nie in der Order und setzte keine Marker — die Routes.quick*-Konstanten sind ersatzlos gelöscht.
Wo darf man nicht trauen?
Pre-Sprint-L-Hive-Slots haben kein panelTiltsAtCapture — der Tilt-Drift-Check fällt dann lautlos auf Azimut-only zurück. Bei Capture-Wiederherstellung aus solchen Slots werden Tilt-only-Änderungen erst beim nächsten Capture-Persistieren wieder sauber erfasst. Akzeptierter Kompromiss, weil die App noch nicht released ist und ein gewaltsamer Re-Sync ohne Nutzen wäre.
Quelle / Code.
shading_consistency.dart—isShadingInvalidated,applySelectiveRecaptureForRef,showShadingRecaptureDialog.shading_label.dart—partitionCapturesByArrayChange.horisol_config.dart—panelArrayAzimuthsAtCapture,panelTiltsAtCapture,locationAtCapture(Snapshot-Felder).load_profile.dart—LoadProfile.applyModeSwitch.shading_screen.dart— ValueKey-basierter Retake-Flow.wizard_routing.dart—routeForConfig(Marker + Prereq-Cap),singleWizardOrder,wizardOrderFor,wizardStepIndex,wizardPredecessor/wizardSuccessor,_remapObsoleteGuidedMarker.wizard_step_progress.dart—WizardStepProgress-Mixin.markStepReachedinproviders.dart.balcony_recommendation_service.dart—recommend(trivialer Vorschlag mit Provenienz für die Results-Card) +applyRecommendation(atomarer Snapshot-Mitzug gegen das Recapture-Paradox; als getestete Utility erhalten, aktuell nicht vom Panel-Prefill aufgerufen).panel_config_screen.dart— Ausrichtung setzen (azimuthConfirmed = truein Drag-Ring + Kompass-Callback, jeweils auf der aktiven Gruppe),nextEnabled-Gate über alle Gruppen + Nudge (benennt die fehlende Gruppe), „+“-Pill-Gate (addGrouperst nach Bestätigung aller Gruppen, Klon startet unbestätigt),_handlePanelConfigDone(Recapture-Dialog zur Navigationszeit).results_screen.dart— read-only Amortisations-Kachel (amortizationYearsForausyearlySavings+investmentCost-Seed; kein Eingabefeld, kein Persistenz-Pfad). Der frühereinteractive_payback_block.dartist mit dem Phase-F/G-Teilrevert gelöscht.
Re-Kalibrierung.
Wenn ein neuer Wizard-Schritt eingeführt wird oder eine bestehende Eingabe semantisch wechselt: Matrix erweitern, im Code an der entsprechenden Stelle einen applyXSwitch / isXInvalidated ergänzen, und einen Test für den Cleanup-Pfad schreiben. Die Soll-Matrix ist die Lieferspezifikation — was hier nicht steht, darf nicht heimlich invalidiert werden.
15. FAQ — wiederkehrende Reviewer-Fragen
Diese Sektion bündelt Fragen, die in externen Code-Reviews und User-Diskussionen regelmäßig aufkommen. Jede Antwort verweist auf die zuständige Sektion oben — die FAQ ist Einstieg, nicht Ersatz.
Warum ist der Verbrauch im Winter nicht höher als im Sommer?
Das BDEW-H25-Standardlastprofil bildet einen Haushalt ohne elektrische Wärmelast ab — und zeigt empirisch Sommer leicht höher als Winter (Faktor 1.14 für den couple-Default, 3.000 kWh/Jahr; verifiziert per Tagessummen-Aggregation). Gründe: längerer Abend-Peak in der hellen Jahreszeit, höhere Kühl-/Gefriergeräte-Last bei Wärme. Wer Wärmepumpe oder Direktheizung aktiviert, bekommt automatisch saisonale Modulation mit Januar-Peak (Saisonfaktor 1.0 + 0.85·cos(...), Sommer-Floor 0.25 für Brauchwasser). Siehe §4.
Warum 18 % Systemverlust? Das wirkt hoch.
defaultLossPercent = 18 deckt Soiling (Verschmutzung), Kabel-/DC-Verluste, Modul-Mismatch, Inverter-Standby, Temperatur-Derate-Reststreuung und Alterungs-Reserve ab. Entspricht dem PVGIS-Default-Stack und ist bewusst konservativ gewählt — Brand-Versprechen lautet “ehrlich, nicht schöngerechnet”. Wer einen niedrigeren Wert sehen will, kann das in der App overriden; default bleibt konservativ. Siehe §8.
Modelliert ihr keine Strompreis-Inflation? Bewusst nicht. Year-1-Ersparnis = exakt das, was auf der nächsten Jahresrechnung steht. Damit ist die ausgewiesene Amortisation konservativ: bei steigenden Strompreisen amortisiert sich die Anlage real schneller, nie langsamer. Strompreis-Inflations-Toggle ist als Premium-Hook angedacht. Siehe §7.
Wie kommt ihr auf die Eigenverbrauchsquote?
Stündliche min(PV, Last)-Überlagerung über alle 12 BDEW-Monatsprofile gegen das stündliche PV-Profil aus PVGIS/PVWatts/NASA POWER. Anschließend Jensen-Bias-Korrektur ×0.85 (kalibriert gegen HTW-Berlin-Stecker-Solar-Simulator über 41 Profile, minütliche Auflösung). Genauigkeit ±5 Prozentpunkte gegen Smart-Meter-Daten. Siehe §6.
Stunden-Auflösung ist doch zu grob für realistische Eigenverbrauchsrechnung?
Strukturell ja — minütliche Spitzen (Wasserkocher 2 kW für 3 Minuten) verschwinden im Stundenmittel. Genau deswegen die 0.85-Korrektur, kalibriert gegen minütliche Smart-Meter-Daten. Volle Minuten-Auflösung würde Modell-Komplexität und Daten-Abhängigkeiten vervielfachen, ohne die Aussage signifikant zu verbessern. Siehe §6.
Warum funktioniert das Lastprofil außerhalb DE? Aktuell fällt jedes Land außerhalb DE auf BDEW H25 zurück. Die Tagesform stimmt grob für Mitteleuropa (CH, AT, BE, NL, FR, PL, CZ); mediterrane Spätabend-Spitzen (IT, ES, GR) und skandinavische Winter-Beleuchtungs-Peaks fehlen. Eine kuratierte Liste publizierter landesspezifischer Profile mit Quellen-URLs steht im Backlog. Siehe §4, §13.
Wie zuverlässig sind die Solar-Klimadaten?
Der SolarDataRouter wählt regional: PVGIS v5.3 für EU/AF/MO (SARAH-2-Satellit + ERA5, ±3 % Jahresertrag vs. Pyranometer), PVWatts v8 für Nordamerika (NSRDB TMY3, ±4 %), NASA POWER als Global-Fallback (MERRA-2, ±5–10 %). Bei Provider-Fehler einmaliger NASA-POWER-Fallback. Siehe §10.
Warum ist die App-Erwartung niedriger als die Verkäufer-Versprechen? Verkäufer-Versprechen sind oft optimal-orientiert: ideale Süd-Ausrichtung, 30°-Tilt, keine Verschattung, neuer Modul-Stack. HoriSol rechnet konservativ und standortspezifisch: realer Azimut/Tilt, fotobasierte Verschattung, 18 % Systemverlust, BDEW-Lastprofil ohne Schönrechnen. Differenz von 15–30 % zur Verkäufer-Angabe ist normal und beabsichtigt. Wenn unsere Zahl höher wäre, würden wir Verkaufs-Material schreiben, nicht Analyse-Software.
16. Fehlerrechnung — Genauigkeits-Budget der Hero-Zahl
Diese Sektion bricht wie §14 bewusst aus der 8-Punkte-Schablone aus: Sie dokumentiert keine Berechnungs-Annahme, sondern die Fehlerfortpflanzung von den Mess-Eingaben (Sensorik, Nutzer-Zeichnung) über den Verschattungsverlust (§3) bis zur Jahres-kWh im Results-Hero — und belegt damit die Genauigkeits-Angaben, die die App nach außen macht (resultsConfidenceBadge „± 10 % typische Genauigkeit”, pdfDisclaimer, Methodik-Screen §3-Text). Erstellt 2026-07-09 anlässlich des Sun-Check-Gerätetests (siehe §1, Ist-Zeit-Ausnahme).
Sensor-Fehlerbudget der Foto-Capture (Referenzgerät iPhone SE 3. Gen; andere Smartphones gleiche Größenordnung).
| Fehlerquelle | typisch | worst case | wirkt auf |
|---|---|---|---|
| Kompass-Heading (CoreMotion, kalibriert) | ±5–10° | ±30°+ direkt neben Metall (Balkongeländer) | Azimut |
| Accelerometer-Pitch (EMA-geglättet, ruhige Hand) | ±1–2° | ±3° | Elevation |
| Nutzer-Zeichnung der Horizontlinie (MarkerStep) | ±1–3° | ±5° | Elevation |
| FOV-Abbildung (lineares Lochkamera-Modell, keine Entzerrung, §3) | ±1–2° am Bildrand | — | beide |
| GPS-Position (±10 m) | <0,01° | vernachlässigbar | beide |
| Uhrzeit (NTP, <1 s) | <0,01° | vernachlässigbar | beide |
| NOAA-Sonnenstand (§1) | <1° (0,8°/1,1° vs. USNO) | — | beide |
| Summe (RSS) | Azimut ~6–12°, Elevation ~3–5° | Azimut 30°+ nur bei massiver Magnetstörung |
Fortpflanzung in den Verschattungsverlust L und die Hero-kWh. Simulation mit dem exakten §3-Algorithmus (12 Repräsentativtage × 10-Min, sin(α)·cos(AoI)-Gewichte, SVF im 5°-Raster, f_diff 0,5, Berlin, Panel Süd, Tilt 90° und 35°): vier Masken-Szenarien (breite Hauswand 25° über 120–240°, schmaler Baum 30° über 168–192°, niedrige Dachkante 12° über 90–270°, starke Verschattung 35° über 100–260°), jeweils gestört um Kompass-Bias (Maske seitlich verschoben) bzw. Elevations-Bias (Maske angehoben/abgesenkt). Hero-Wirkung = relative Änderung von yearlyKwh · (1 − L/100):
| Störung | ΔL | Wirkung auf Hero-kWh |
|---|---|---|
| Azimut ±10° (Kompass typisch) | ≤ 0,3 Pp | ≤ 0,5 % |
| Azimut ±15° (Kompass worst case) | ≤ 0,7 Pp | ≤ 1,1 % |
| Elevation ±3° (Accelerometer + Zeichnung, realistisch) | 2–4 Pp | 3–5 % (Extremfall vertikales Modul + breite Wand: bis 7 %) |
| Elevation ±5° (unsorgfältige Horizontlinie) | 4–7 Pp | 5–10 % |
Zwei Kernaussagen.
- Der Kompass — der mit Abstand schlechteste Sensor — ist für die Hero-Zahl fast irrelevant. Eine seitlich verschobene Maske ändert das Jahresintegral kaum, weil die Sonnenbahn trotzdem durch dieselbe Hinderniskulisse läuft. Kompass-Genauigkeit zählt für die Overlay-Plausibilität und den Sun-Genauigkeits-Check (UX-Vertrauen), nicht für die kWh.
- Der Ertrag hängt an der Elevations-Achse — und die speist sich aus dem genauesten Sensor (Accelerometer ±1–2°) plus der Sorgfalt der gezeichneten Horizontlinie. Die Zeichen-Sorgfalt des Nutzers ist der größte Einzelhebel; die Methodik ist strukturell robust gegen Sensor-Schwächen.
Gesamtbudget der Hero-Zahl. Unabhängige Fehlerquellen, quadratisch addiert (RSS): Klimadaten PVGIS SARAH-2 ±3 % (§10) ⊕ Wetterjahr-Streuung gegen die Klimatologie ±5–8 % ⊕ Verschattungsmessung ±3–5 % (oben) ⊕ Systemverlust-Pauschale (§8, konservativ, wirkt einseitig) ≈ ±8–10 %. Das resultsConfidenceBadge („± 10 % typische Genauigkeit”) und der pdfDisclaimer sind damit korrekt kalibriert — seit dieser Sektion belegt statt geschätzt. Die Eigenverbrauchs-Kette (§6, ±5 Pp) kommt für Ersparnis/Amortisation zusätzlich obendrauf und ist dort separat ausgewiesen.
Wo darf man nicht trauen?
- Nahfeld-Verschattung (<20 m) mit Parallaxe über die Modulbreite — siehe §3-Grenze „Ausgedehnte Anlagen”.
- Systematisch falsch gezeichnete Horizontlinie (z. B. Geländer als Horizont) — kein Sensor fängt das.
- Wetterjahre weit außerhalb der Klimatologie (Extremjahre): die ±10 % sind ein typisches, kein garantiertes Band.
Re-Kalibrierung.
Bei jeder Änderung am §3-Algorithmus (Maske, Gewichte, SVF, f_diff) die Fortpflanzungs-Simulation wiederholen: §3-Formeln in ein Wegwerf-Skript replizieren (Masken-Szenarien oben), Störungen ±10/±15° Azimut und ±3/±5° Elevation durchrechnen, Tabelle hier aktualisieren. Alternativ als ShadingLab-Experiment am Gerät (Backlog §13).
Reproduzierbarkeit
Alle Modelle in lib/data/ sind Pure-Dart, deterministisch, ohne IO. Fixtures unter code/horisol/assets/fixtures/shading/ (synthetisch) und data/jrc_testdata_1000wp_180_90/ (PVGIS-Referenz, untracked) ermöglichen Regressions-Checks via:
dart run code/horisol/tool/shading_lab.dart <fixture.json>
Tests: 293 Tests grün (Stand 2026-05-23). Architecture-Boundary-Test (test/architecture/no_flutter_imports_test.dart) sichert die Pure-Domain-Schicht gegen Flutter-/Plugin-Imports.
Zusammenfassung in einem Satz
HoriSol rechnet konservativ, dokumentiert jede Annahme, gewichtet ehrlich nach Standort und benennt explizit, wo das Modell vereinfacht. Wer eine schöne Zahl will, ist hier falsch — wer eine ehrliche Zahl will, hat hier eine.