PV Vorhersage: rein mit Wetter und historischen Daten

Preview PV Vorhersage: rein mit Wetter und historischen Daten

Die meisten PV-Prognosen basieren auf Standard-Wettermodellen. Diese wissen aber nicht, ob ein Baum deine Paneele verschattet oder wie effizient deine Anlage bei diffusem Licht wirklich ist. Die Lösung: Historischer Vergleich. Wir schauen, wie viel Strom deine Anlage in der Vergangenheit bei exakt derselben Bewölkung geliefert hat: Egal welcher Winkel oder wie viele unterschiedliche PV-Flächen: Verwendet werden lediglich die historischen PV-Erträge und Bewölkungsgrade vergangener Tage: Verglichen mit der Bewölkungsvorhersage des heutigen oder der kommenden Tage. Für ein schnelles Setup habe ich eine HACS-Erweiterung erstellt. Alternativ funktioniert die Prognose auch mit Home Assistant Board mitteln: rein die Standard-Wettervorhersage und ein SQL-Sensor mit Wert-Template. Optional kann für die Anzeige der Bewölkungsgrade eine Markdown-Card erstellt werden, um die Berechnung zu verstehen:

Das Problem mit klassischen PV-Prognosen

Externe PV-Prognosedienste (Forecast.Solar, Solcast & Co.) schätzen den Ertrag auf Basis von Wetterdaten und konfigurierter Anlagenleistung. Das funktioniert für eine Grobplanung, ist aber für den Tagesbetrieb oft zu ungenau:

  • Die tatsächliche Verschattung durch Bäume, Gebäude oder Schnee wird nicht berücksichtigt
  • Die effektive Leistung der Anlage (Alterung, Verschmutzung) ist unbekannt
  • Lokale Wetterphänomene wie Bodennebel oder schnell wechselnde Bewölkung werden falsch bewertet

Besser: Das Wissen über den Ertrag und die Bewölkung steckt bereits in der Home Assistant Datenbank – man muss es nur auslesen.

HACS Integration

SoftwareHa_pv_history_forecast
GitHubhttps://github.com/LiBe-net/ha_pv_history_forecast
aktuelle Version 0.2.0
gefunden15.04.2026

Mit PV History Forecast habe ich eine Custom Integration für Home Assistant erstellt (installierbar über HACS), die direkt auf die SQLite-Datenbank von Home Assistant zugreift. Die damit verwendete SQL-Abfrage sucht historische Tage mit ähnlicher Bewölkung für den ganzen Tag und den restlichen Tagesverlauf.
Diese Vergleichstage werden dann:
  • Saisonal skaliert – ein Sommertag wird auf den aktuellen Herbsttag normiert, unter Berücksichtigung von Tageslänge und Sonnenstand (astronomisch korrekte Formel nach Breitengrad)
  • Dynamisch UV-gewichtet – die Abstandsmetrik kombiniert Bewölkungs- und UV-Index-Abstand; bei starker Bewölkung (≥60 %) steigt die UV-Gewichtung automatisch von 30 % auf bis zu 70 %, weil der UV-Index dann der präzisere Diskriminator ist
  • Recency-gewichtet – Tage jünger als 30 Tage erhalten bis zu 30 % mehr Gewicht (aktuelles Anlagenverhalten zählt mehr)
  • LOO-validiert – Leave-One-Out-Kreuzvalidierung erkennt und down-gewichtet Ausreißer im Pool automatisch
  • Trend-gedämpft – wenn die jüngsten 14 Tage mehr als 15 % über dem älteren Durchschnitt liegen, wird die Hälfte des Überschusses abgezogen
  • Back-Test-gesteuert – der Carry-Through-Faktor für den gestrigen Minderertrag wird datenbasiert aus aufeinanderfolgenden Schlechtwetterpaaren im Pool abgeleitet
  • Cloud-gated Penalty – wenn gestern ein Minderertrag aufgetreten ist und heute ebenfalls ≥60 % Bewölkung forecast werden, wird die Prognose konservativ angepasst
  • Gemittelt oder interpoliert – je nach verfügbarer Datenlage (Weighted average, Light reduction oder Max assumption)
Das Ergebnis: Eine Restprognose für heute, die auf echten Daten der eigenen Anlage basiert und mit vier Korrekturstufen gegen systematische Überschätzungen abgesichert ist.

Erzeugte Sensoren

Nach der Installation und Konfiguration (Standardpräfix: `pv_hist`) stehen folgende Sensoren bereit:

Sensor Bedeutung
sensor.pv_hist_remaining_today Voraussichtlicher Rest-Ertrag heute in kWh (Hauptsensor)
sensor.pv_hist_remaining_today_min Pessimistischer Tagesrest (wolkigere ähnliche Tage)
sensor.pv_hist_remaining_today_max Optimistischer Tagesrest (hellere ähnliche Tage)
sensor.pv_hist_tomorrow Gewichtete Prognose für den Gesamtertrag morgen in kWh
sensor.pv_hist_weather_forecast Interner Hilfssensor: stündliche Wettervorhersage als JSON
sensor.pv_hist_cloud_coverage Auto-Bewölkungssensor: spiegelt cloud_coverage der Wetter-Entity (wenn kein externer Sensor gewählt); ab Tag 1 nutzbar, LTS-Verlauf (>10 Tage) baut sich automatisch auf
sensor.pv_hist_cloud_remaining_today Durchschnitt der noch verbleibenden Bewölkung innerhalb der Sonnenstunden für den verbleibenden Tag
sensor.pv_hist_cloud_tomorrow Durchschnitt der noch verbleibenden Bewölkung innerhalb der Sonnenstunden für den kommenden Tag
sensor.pv_hist_method_remaining_today angewandte Methode für die Berechnung der Vorhersage für den heutigen Tag
sensor.pv_hist_method_tomorrow angewandte Methode für die Berechnung der Vorhersage für den kommenden Tag
sensor.pv_hist_uv_remaining_today Durchschnittlicher UV-Index für den verbleibenden heutigen Tag (innerhalb der Sonnenstunden)
sensor.pv_hist_uv_tomorrow Durchschnittlicher UV-Index für den kommenden Tag (innerhalb der Sonnenstunden)
sensor.pv_hist_uv Auto-UV-Sensor: spiegelt den UV-Index der Wetter-Entity (wenn kein externer UV-Sensor gewählt); baut LTS-Verlauf automatisch auf

Der Hauptsensor `sensor.pv_hist_remaining_today` enthält zusätzlich die Attribute `lovelace_card_remaining_today` und `lovelace_card_tomorrow` – fertig gerenderte Markdown-Karten, die direkt im Dashboard eingebunden werden können.

Vereinfachte Funktion:

  • cloud_history: Liest historische Bewölkungswerte aus den HA-Statistiken (LTS)
  • matching_days: Sucht Tage, an denen der Bewölkungsschnitt im Tagesrest dem heutigen ähnelt
  • final_data: Berechnet für jeden Vergleichstag den skalierten Ertrag und gibt das Ergebnis als JSON zurück

Die Skalierung zwischen Vergleichstag und heutigem Tag erfolgt über eine astronomisch korrekte Tageslängenformel:

dl = 24/π · arccos(−tan(φ) · tan(δ))
δ  = −0.4093 · cos(2π · (Tag + 10) / 365)

Zusätzlich gibt es eine saisonale Schneeerkennung (Dezember–Februar): Wenn der gestrige Ertrag beim vorhandenen Sonnenpotenzial auffällig niedrig war, wird ein Schneefaktor angewendet.

Installation

Die Integration wird über HACS als Custom Repository installiert: HACS öffnen → ⋮ → Benutzerdefiniertes Repository hinzufügen

URL:

LiBe-net/ha_pv_history_forecast
  • in HACS: "PV History Forecast" suchen und installieren
  • Home Assistant neu starten
  • Einstellungen → Geräte & Dienste → Integrationen → „PV History Forecast" hinzufügen

Konfiguration

Die Konfiguration erfolgt vollständig über die HA-Benutzeroberfläche in zwei Schritten.

Schritt 1 – Präfix & Datenbank

Feld Standard Beschreibung
Sensor-Präfix pv_hist Basis für alle Sensornamen
Datenbankpfad (leer) Leer lassen = Standard-HA-Datenbank

Schritt 2 – Sensoren

Feld Pflicht Beschreibung
Wetter-Entity `weather.*`-Entity, z. B. `weather.forecast_home`
PV-Energiesensor Sensor mit `device_class: energy` und aktiven Statistiken; Wh wird automatisch in kWh umgerechnet
Bewölkungssensor Optional Sensor mit Einheit %; leer lassen = sensor.pv_hist_cloud_coverage wird automatisch angelegt (ab Tag 1 nutzbar)
UV-Index-Sensor Optional Sensor für historische UV-Index-Werte (verbessert die Gewichtung bei klarem Himmel); leer lassen = sensor.pv_hist_uv wird automatisch angelegt
Verlaufstage Standard: 30 Anzahl historischer Tage für den Vergleich

Tipp: Die Dropdowns im Konfigurationsschritt zeigen nur passende Sensoren an – PV-Sensoren werden nach `device_class: energy` und aktiven Statistiken gefiltert, Bewölkungssensoren nach Einheit `%`.

Das Lovelace Dashboard

Die fertigen Markdown-Karten sind als Attribut des Hauptsensors verfügbar und zeigen

  • Die berechnete Restprognose in kWh als Headline
  • Den aktuellen Bewölkungsdurchschnitt und die verwendete Berechnungsmethode
  • Eine Tabelle der historischen Vergleichstage mit Tag-Bewölkung, Tag-Ertrag, Rest-Bewölkung, Rest-Ertrag und prozentualem Einflussgewicht

Einbindung in eine Lovelace Markdown-Card heute:

type: markdown
content: |
  Vorhersage heute verbleibend: <b><big>{{ states.sensor.pv_hist_remaining_today.state | round(2)}} kWh</big></b>
  restliche Bewölkung: <b><big>{{ states.sensor.pv_hist_cloud_remaining_today.state }}%</big></b>
  Calc Methode: {{ states.sensor.pv_hist_method_remaining_today.state }}
  <center>

  {{ state_attr('sensor.pv_hist_remaining_today', 'lovelace_card_remaining_today') }}

morgen:

type: markdown
content: |
  Vorhersage morgen: <b><big>{{ states.sensor.pv_hist_tomorrow.state | round(2)}} kWh</big></b>
  Bewölkung morgen: <b><big>{{ states.sensor.pv_hist_cloud_tomorrow.state }}%</big></b>
  Calc Methode: {{ states.sensor.pv_hist_method_tomorrow.state }}
  <center>

  {{ state_attr('sensor.pv_hist_remaining_today', 'lovelace_card_tomorrow') }}

Voraussetzungen

  • Home Assistant 2024.1.0 oder neuer
  • Standard Datenbank (sqlite)
  • HACS installiert und eingerichtet
  • PV-Sensor: state_class: total_increasing, Einheit kWh oder Wh, aktive Statistiken
  • Wetter-Entity mit cloud_coverage in der Forecast-Antwort (z. B. weather.forecast_home)
  • Optional: externer Bewölkungssensor mit Einheit % (sonst: Auto-Sensor ab Tag 1 nutzbar)
  • Optional: externer UV-Index-Sensor (sonst: Auto-Sensor ab Tag 1 nutzbar)

Ohne HACS: rein mit der SQL-Integration

Bevor ich die HACS-Integration auf Github erstellt habe, habe ich die Vorhersage rein über die SQL-Integration umgesetzt: Hier zum Nachbauen:

Voraussetzungen Sensor für Wettervorhersage

Sensorname normalerweise: weather.forecast_home

Vorhersage-Daten speichern

Anpassung: configuration.yaml: Wettervorhersage in einem Sensor speichern: weather.forecast_hourly

[+]
template:
  - trigger:
      - platform: time_pattern
        minutes: /15
    action:
      - service: weather.get_forecasts
        data:
          type: hourly
        target:
          entity_id: weather.forecast_home
        response_variable: hourly
    sensor:
      - name: weather.forecast_hourly
        unique_id: weather.forecast_hourly
        state: "{{ now().isoformat() }}"
        attributes:
          forecast:  "{{ hourly['weather.forecast_home'].forecast }}"

Optional: Mehr als 10 Tage für die Berechnung verwenden.

Für mehr als 10 Tage benötigen wir einen Hilfssensor damit die Bewölkungswerte in die Langzeitstatistik geschrieben werden. 

Sensorname: weather.cloud_coverage

{{ state_attr("weather.forecast_home",'cloud_coverage') | float}}

Und für den UV-Index:

Sensorname: weather.uv_index

{{ state_attr("weather.home",'uv_index') | float}}

SQL-Sensor mit Wert Template

Für den SQL-Sensor werden folgende Quell-Sensoren benötigt:

  • weather.forecast_home – liefert historische Bewölkungsgrade (cloud_coverage) und UV-Index direkt aus den State-Attributen
  • sensor.weather_forecast_hourly – Stundenvorhersage (Anlage in configuration.yaml wie oben beschrieben)
  • sensor.pv_panels_energy – Gesamtzähler für den PV-Ertrag (state_class: total_increasing, Einheit kWh)

Die drei Sensor-Namen werden in der vars-CTE des SQL angepasst. Die Abfrage entspricht der Logik der HACS-Integration und liefert dasselbe JSON-Format: Bewölkung, UV-Index und PV-Erträge pro Vergleichstag.

[+]
WITH vars AS (
    SELECT 
        'weather.forecast_home' as sensor_clouds,   -- Weather Entity direkt: cloud_coverage aus state_attributes
        'weather.forecast_home' as sensor_uv,   -- Weather Entity direkt:  uv_index aus state_attributes
        'sensor.pv_panels_energy' as sensor_pv,
        'sensor.weather_forecast_hourly' as sensor_forecast,
        -- Berechnet den Versatz zwischen Lokalzeit und UTC (z.B. '+3600 seconds')
        -- Wird genutzt, um den Datumswechsel (00:00 Uhr) lokal zu triggern
        (strftime('%s', 'now', 'localtime') - strftime('%s', 'now')) || ' seconds' as offset
),

ids AS (
    /* Holt alle benötigten internen IDs für Statistiken und States aus der HA-Datenbank */
    SELECT 
        (SELECT id FROM statistics_meta WHERE statistic_id = (SELECT sensor_clouds FROM vars)) as w_id_stats,
        (SELECT metadata_id FROM states_meta WHERE entity_id = (SELECT sensor_clouds FROM vars)) as w_id_states,
        (SELECT id FROM statistics_meta WHERE statistic_id = (SELECT sensor_uv FROM vars)) as uv_id_stats,
        (SELECT metadata_id FROM states_meta WHERE entity_id = (SELECT sensor_uv FROM vars)) as uv_id_states,
        (SELECT id FROM statistics_meta WHERE statistic_id = (SELECT sensor_pv FROM vars) LIMIT 1) as p_id,
        (SELECT metadata_id FROM states_meta WHERE entity_id = (SELECT sensor_pv FROM vars) LIMIT 1) as p_id_states,
        (SELECT metadata_id FROM states_meta WHERE entity_id = (SELECT sensor_forecast FROM vars) LIMIT 1) as f_id,
        (SELECT metadata_id FROM states_meta WHERE entity_id = 'sun.sun') as sun_id
),

pv_activity AS (
    /* Sonnenaufgang = erster 'above_horizon'-Eintrag gestern (UTC-Epoche, direkt korrekt)       */
    /* Sonnenuntergang = erster 'below_horizon'-Eintrag NACH dem Sonnenaufgang gestern           */
    /* sun_start/sun_end = UTC HH:MM  → verwendet für BETWEEN mit UTC-Forecast-Datetimes (+00:00) */
    /* sun_start_local/sun_end_local = lokal HH:MM → nur für Phasenerkennung (vor/nach Auf/Unt)  */
    SELECT
        COALESCE((
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch')
            FROM states
            WHERE metadata_id = (SELECT sun_id FROM ids)
              AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day')
              AND state = 'above_horizon'
            ORDER BY last_updated_ts ASC LIMIT 1
        ), '05:30') as sun_start,
        COALESCE((
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch')
            FROM states
            WHERE metadata_id = (SELECT sun_id FROM ids)
              AND state = 'below_horizon'
              AND last_updated_ts > (
                  SELECT last_updated_ts FROM states
                  WHERE metadata_id = (SELECT sun_id FROM ids)
                    AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day')
                    AND state = 'above_horizon'
                  ORDER BY last_updated_ts ASC LIMIT 1
              )
            ORDER BY last_updated_ts ASC LIMIT 1
        ), '17:30') as sun_end,
        COALESCE((
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch', (SELECT offset FROM vars))
            FROM states
            WHERE metadata_id = (SELECT sun_id FROM ids)
              AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day')
              AND state = 'above_horizon'
            ORDER BY last_updated_ts ASC LIMIT 1
        ), '06:30') as sun_start_local,
        COALESCE((
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch', (SELECT offset FROM vars))
            FROM states
            WHERE metadata_id = (SELECT sun_id FROM ids)
              AND state = 'below_horizon'
              AND last_updated_ts > (
                  SELECT last_updated_ts FROM states
                  WHERE metadata_id = (SELECT sun_id FROM ids)
                    AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day')
                    AND state = 'above_horizon'
                  ORDER BY last_updated_ts ASC LIMIT 1
              )
            ORDER BY last_updated_ts ASC LIMIT 1
        ), '18:30') as sun_end_local
    FROM ids
),

forecast_val AS (
    /* Berechnet die durchschnittliche Bewölkung + UV-Index für den verbleibenden Teil des aktuellen Tages */
    SELECT COALESCE(
        (SELECT AVG(CAST(json_extract(f.value, '$.cloud_coverage') AS FLOAT)) 
         FROM states s 
         JOIN state_attributes a ON s.attributes_id = a.attributes_id, 
         json_each(a.shared_attrs, '$.forecast') f 
         WHERE s.metadata_id = (SELECT f_id FROM ids) 
           AND s.last_updated_ts = (SELECT MAX(last_updated_ts) FROM states WHERE metadata_id = (SELECT f_id FROM ids)) 
           -- Match forecast date against local "today" (via UTC offset)
           AND substr(json_extract(f.value, '$.datetime'), 1, 10) = date('now', (SELECT offset FROM vars))
           AND substr(json_extract(f.value, '$.datetime'), 12, 5) 
               BETWEEN CASE
                         -- Forecast slots are UTC: compare against UTC sun_start/sun_end.
                         -- Only the window START shifts: during local day use current UTC time
                         -- (remaining today); before/after local daylight use full-day window
                         -- (midnight use-case: forecast for the whole coming day).
                         WHEN strftime('%H:%M', 'now', (SELECT offset FROM vars))
                              BETWEEN (SELECT sun_start_local FROM pv_activity)
                                  AND (SELECT sun_end_local   FROM pv_activity)
                             THEN strftime('%H:%M', 'now')
                         ELSE (SELECT sun_start FROM pv_activity)
                       END
               AND (SELECT sun_end FROM pv_activity)
        ), 50.0) as f_avg,
        COALESCE(
        (SELECT AVG(CAST(json_extract(f.value, '$.uv_index') AS FLOAT)) 
         FROM states s 
         JOIN state_attributes a ON s.attributes_id = a.attributes_id, 
         json_each(a.shared_attrs, '$.forecast') f 
         WHERE s.metadata_id = (SELECT f_id FROM ids) 
           AND s.last_updated_ts = (SELECT MAX(last_updated_ts) FROM states WHERE metadata_id = (SELECT f_id FROM ids)) 
           AND substr(json_extract(f.value, '$.datetime'), 1, 10) = date('now', (SELECT offset FROM vars))
           AND substr(json_extract(f.value, '$.datetime'), 12, 5) 
               BETWEEN CASE
                         WHEN strftime('%H:%M', 'now', (SELECT offset FROM vars))
                              BETWEEN (SELECT sun_start_local FROM pv_activity)
                                  AND (SELECT sun_end_local   FROM pv_activity)
                             THEN strftime('%H:%M', 'now')
                         ELSE (SELECT sun_start FROM pv_activity)
                       END
               AND (SELECT sun_end FROM pv_activity)
        ), 0.0) as uv_avg
),

forecast_next_day AS (
    /* Berechnet die durchschnittliche Bewölkung + UV-Index für den gesamten nächsten Tag */
    SELECT COALESCE((
        SELECT AVG(CAST(json_extract(f.value, '$.cloud_coverage') AS FLOAT)) 
        FROM states s 
        JOIN state_attributes a ON s.attributes_id = a.attributes_id, 
        json_each(a.shared_attrs, '$.forecast') f 
        WHERE s.metadata_id = (SELECT f_id FROM ids) 
          AND s.last_updated_ts = (SELECT MAX(last_updated_ts) FROM states WHERE metadata_id = (SELECT f_id FROM ids)) 
          AND substr(json_extract(f.value, '$.datetime'), 1, 10) = date('now', (SELECT offset FROM vars), '+1 day') 
          AND substr(json_extract(f.value, '$.datetime'), 12, 5) BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity)
    ), 50.0) as f_avg_tomorrow,
    COALESCE((
        SELECT AVG(CAST(json_extract(f.value, '$.uv_index') AS FLOAT)) 
        FROM states s 
        JOIN state_attributes a ON s.attributes_id = a.attributes_id, 
        json_each(a.shared_attrs, '$.forecast') f 
        WHERE s.metadata_id = (SELECT f_id FROM ids) 
          AND s.last_updated_ts = (SELECT MAX(last_updated_ts) FROM states WHERE metadata_id = (SELECT f_id FROM ids)) 
          AND substr(json_extract(f.value, '$.datetime'), 1, 10) = date('now', (SELECT offset FROM vars), '+1 day') 
          AND substr(json_extract(f.value, '$.datetime'), 12, 5) BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity)
    ), 0.0) as uv_avg_tomorrow
),

cloud_history AS (
        /* Kombiniert Langzeit-Statistiken und kurzfristige States der Bewölkung für den historischen Vergleich */
        SELECT start_ts as ts,
                     CAST(COALESCE(mean, state) AS FLOAT) as val
        FROM statistics 
        WHERE metadata_id = (SELECT w_id_stats FROM ids) 
            AND start_ts > strftime('%s', 'now', '-60 days')
        UNION ALL
        SELECT s.last_updated_ts as ts, 
            CASE WHEN (SELECT sensor_clouds FROM vars) LIKE 'weather.%' 
                     THEN CAST(json_extract(a.shared_attrs, '$.cloud_coverage') AS FLOAT) 
                     ELSE CAST(s.state AS FLOAT) 
            END as val
        FROM states s 
        LEFT JOIN state_attributes a ON s.attributes_id = a.attributes_id 
        WHERE s.metadata_id = (SELECT w_id_states FROM ids) 
            AND ((SELECT sensor_clouds FROM vars) LIKE 'weather.%' OR NOT EXISTS (SELECT 1 FROM statistics WHERE metadata_id = (SELECT w_id_stats FROM ids)))
            AND s.last_updated_ts > strftime('%s', 'now', '-10 days') 
            AND s.state NOT IN ('unknown', 'unavailable', '')
),

uv_history AS (
        /* Kombiniert Langzeit-Statistiken und kurzfristige States des UV-Index für den historischen Vergleich */
        SELECT start_ts as ts,
                     CAST(COALESCE(mean, state) AS FLOAT) as uv_val
        FROM statistics 
        WHERE metadata_id = (SELECT uv_id_stats FROM ids) 
            AND start_ts > strftime('%s', 'now', '-60 days')
        UNION ALL
        SELECT s.last_updated_ts as ts, 
            CASE WHEN (SELECT sensor_uv FROM vars) LIKE 'weather.%' 
                     THEN CAST(json_extract(a.shared_attrs, '$.uv_index') AS FLOAT) 
                     ELSE CAST(s.state AS FLOAT) 
            END as uv_val
        FROM states s 
        LEFT JOIN state_attributes a ON s.attributes_id = a.attributes_id 
        WHERE s.metadata_id = (SELECT uv_id_states FROM ids) 
            AND ((SELECT sensor_uv FROM vars) LIKE 'weather.%' OR NOT EXISTS (SELECT 1 FROM statistics WHERE metadata_id = (SELECT uv_id_stats FROM ids)))
            AND s.last_updated_ts > strftime('%s', 'now', '-10 days') 
            AND s.state NOT IN ('unknown', 'unavailable', '')
),

matching_days AS (
    /* Findet vergangene Tage, deren Bewölkungs- und UV-Profil dem heutigen Forecast am nächsten kommt */
    SELECT 
        date(c.ts, 'unixepoch') as day, 
        AVG(CASE WHEN strftime('%H:%M', c.ts, 'unixepoch') BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity) THEN c.val END) as h_avg_total_val,
        AVG(CASE WHEN strftime('%H:%M', c.ts, 'unixepoch') >= strftime('%H:00', 'now') AND strftime('%H:%M', c.ts, 'unixepoch') <= (SELECT sun_end FROM pv_activity) THEN c.val END) as h_avg_rest_val,
        AVG(CASE WHEN strftime('%H:%M', u.ts, 'unixepoch') BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity) THEN u.uv_val END) as uv_avg_total_val,
        AVG(CASE WHEN strftime('%H:%M', u.ts, 'unixepoch') >= strftime('%H:00', 'now') AND strftime('%H:%M', u.ts, 'unixepoch') <= (SELECT sun_end FROM pv_activity) THEN u.uv_val END) as uv_avg_rest_val
    FROM cloud_history c
    JOIN uv_history u ON date(c.ts, 'unixepoch') = date(u.ts, 'unixepoch')
    -- Filtert die Historie: Alles vor dem heutigen lokalen Tag (Offset-gesteuert)
    WHERE date(c.ts, 'unixepoch') < date('now', (SELECT offset FROM vars)) 
    GROUP BY 1 
    HAVING h_avg_total_val IS NOT NULL AND h_avg_total_val > 0
    ORDER BY ABS(
        COALESCE(h_avg_rest_val, h_avg_total_val) -- Fallback auf Gesamt-Schnitt wenn Rest null ist
        - (SELECT f_avg FROM forecast_val)
    ) ASC
),

final_data AS (
    /* Ermittelt die realen PV-Erträge der passendsten historischen Tage */
    SELECT 
        md.*,
        (SELECT MAX(state) FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day) as day_max,
        (SELECT MIN(state) FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day AND state > 0) as day_min,
        COALESCE((SELECT state FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day AND strftime('%H', start_ts, 'unixepoch') = strftime('%H', 'now') LIMIT 1), (SELECT MIN(state) FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day AND state > 0)) as h_hour_curr,
        COALESCE((SELECT state FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day AND strftime('%H', start_ts, 'unixepoch') = strftime('%H', 'now', '-1 hour') LIMIT 1), (SELECT MIN(state) FROM statistics WHERE metadata_id = (SELECT p_id FROM ids) AND date(start_ts, 'unixepoch') = md.day AND state > 0)) as h_hour_prev
    FROM matching_days md
)

/* Generiert das finale JSON-Objekt für Home Assistant */
SELECT json_group_array(
    json_object(
        'date', day,
        'f_avg_today_remaining', (SELECT ROUND(f_avg, 1) FROM forecast_val),        
        'f_avg_tomorrow', (SELECT ROUND(f_avg_tomorrow, 1) FROM forecast_next_day),
        'uv_avg_today_remaining', (SELECT ROUND(uv_avg, 1) FROM forecast_val),
        'uv_avg_tomorrow', (SELECT ROUND(uv_avg_tomorrow, 1) FROM forecast_next_day),
        'h_avg_total', ROUND(h_avg_total_val, 1),
        /* COALESCE: before sunrise h_avg_rest_val is NULL (UTC window '23:xx'..'17:xx' empty) */
        /* Fall back to h_avg_total_val so Jinja cloud-matching works correctly at midnight.   */
        'h_avg_remaining', ROUND(COALESCE(h_avg_rest_val, h_avg_total_val), 1),
        'uv_avg_total', ROUND(uv_avg_total_val, 1),
        'uv_avg_remaining', ROUND(COALESCE(uv_avg_rest_val, uv_avg_total_val), 1),
        'yield_day_total', ROUND(day_max - day_min, 2),
        'yield_day_remaining', ROUND(CASE
            WHEN strftime('%H:%M', 'now', (SELECT offset FROM vars)) > (SELECT sun_end_local   FROM pv_activity)
                THEN 0.0
            WHEN strftime('%H:%M', 'now', (SELECT offset FROM vars)) < (SELECT sun_start_local FROM pv_activity)
                THEN (day_max - day_min)
            ELSE MAX(0, 
                ((h_hour_curr - h_hour_prev) * (1.0 - (CAST(strftime('%M', 'now') AS FLOAT) / 60.0)) * 
                  CASE 
                    WHEN strftime('%H', 'now') = strftime('%H', (SELECT sun_start FROM pv_activity)) THEN 0.85
                    WHEN strftime('%H', 'now') = strftime('%H', (SELECT sun_end FROM pv_activity)) THEN 0.70
                    ELSE 1.0 
                  END)
                + (day_max - h_hour_curr)
            )
        END, 2),
        'pv_start', (SELECT sun_start FROM pv_activity),
        'pv_end', (SELECT sun_end FROM pv_activity)
    )
) as json FROM final_data WHERE day_max > 0;

Solltest du wie vorhin beschrieben einen eigenen Template-Sensor für den Bewölkungsgrad angelegt haben, muss dieser zunächst einige Tage Daten sammeln. Im Anschluss kann die Variable 'weather.forecast_home' as sensor_clouds durch den Template-Sensor ersetzt werden:

...
 'sensor.weather_cloud_coverage' as sensor_clouds,
 'sensor.weather_uv_index' as sensor_uv,
...

Damit können mehr als 10 Tage für die Ermittlung der PV-Vorhersage verwendet werden.

Die SQL-Abfrage liefert pro Vergleichstag ein JSON-Objekt mit Bewölkung, UV-Index und PV-Ertrag. Das Matching kombiniert Bewölkungs- und UV-Abstandsmetrik mit dynamischer Gewichtung (über die uv_w-Formel) und bevorzugt jüngere Tage durch einen Recency-Bonus (+30 % für Tage <30 Tage). Die besten 15 Treffer werden als Berechnungsbasis verwendet.

Wert-Template (Hauptsensor – heute verbleibend)

Der SQL-Sensor-Name lautet: sensor.pv_remaining_states (Attribut: json). Das Template liest dieses Attribut aus und berechnet die Restprognose in 10 Schritten:

[+]
{# PV FORECAST: Remaining yield today  weighted avg + LOO + trend damping + back-test + cloud-gated penalty #}
{% set raw = value if value is defined else state_attr('sensor.pv_remaining_states', 'json') %}

{% if raw and raw != '[]' and raw is not none %}
  {% set data = raw | from_json %}

  {# --- 0. NIGHT-CHECK --- #}
  {% set offset_min = (now().utcoffset().total_seconds() / 60) | int %}
  {% set pv_end_utc = data[0].pv_end | default('17:30') %}
  {% set end_min_local = ((pv_end_utc.split(':')[0] | int) * 60 + (pv_end_utc.split(':')[1] | int) + offset_min) % 1440 %}
  {% if (now().hour * 60 + now().minute) > end_min_local %}
    0.0
  {% else %}

    {# --- 1. BASE DATA --- #}
    {% set f_avg = data[0].f_avg_today_remaining | float(default=50.0) %}
    {% set current_month = now().month %}
    {% set snow_factor_today = 1.0 %}

    {# --- 2. SEASONAL SNOW DETECTION (Dec/Jan/Feb) --- #}
    {% if current_month in [12, 1, 2] %}
      {% set yesterday_date = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
      {% set yesterday_data = data | selectattr('date', 'equalto', yesterday_date) | list | first %}
      {% if yesterday_data is defined %}
        {% set yesterday_perf = yesterday_data.yield_day_remaining | float(0) / ([105 - yesterday_data.h_avg_remaining | float(0), 5] | max) %}
        {% if yesterday_perf < 0.02 %}{% set snow_factor_today = 0.1 %}{% endif %}
      {% endif %}
    {% endif %}

    {# --- 3. ASTRONOMICAL BASE DATA --- #}
    {% set doy = now().strftime('%j') | int(default=1) %}
    {% set latitude = latitude if latitude is defined else state_attr('zone.home', 'latitude') | float(48.0) %}
    {% set lat_rad = latitude * pi / 180 %}
    {% set decl = -0.4093 * cos(2 * pi * (doy + 10) / 365) %}
    {% set dl_today = 24 / pi * acos([[(-tan(lat_rad) * tan(decl)), -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.80 + 0.20 * cos((doy - 172) * 2 * pi / 365) %}

    {# --- 4. BUILD DATA POOL (dynamic UV weight, Recency-Bonus, Top 15) --- #}
    {% set f_uv_avg = data[0].uv_avg_today_remaining | float(default=0.0) %}
    {# uv_w scales with forecast cloud: 0% clouds  30%, 100% clouds  70% #}
    {% set uv_w = [0.3 + 0.4 * (f_avg / 100.0), 0.7] | min %}
    {% set ns_pool = namespace(items=[], total_w=0) %}
    {% for item in data %}
      {% set yield_raw = item.yield_day_remaining | float(default=0) %}
      {% set clouds = item.h_avg_remaining | float(default=0) %}
      {% set uv_hist = item.uv_avg_remaining | float(default=0) %}
      {% set dt_item = as_datetime(item.date) %}
      {% if dt_item is not none %}
        {% set item_day = dt_item.strftime('%j') | int(default=1) %}
        {% set decl_i = -0.4093 * cos(2 * pi * (item_day + 10) / 365) %}
        {% set dl_item = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_i)), -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.80 + 0.20 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set y_korr = yield_raw * s_korr %}
        {% set diff_c = (clouds - f_avg) | abs %}
        {% if f_uv_avg > 0 %}
          {% set diff = diff_c * (1.0 - uv_w) + (uv_hist - f_uv_avg) | abs * 8.0 * uv_w %}
        {% else %}
          {% set diff = diff_c %}
        {% endif %}
        {% set days_ago = ((now().timestamp() - dt_item.timestamp()) / 86400) | int(0) %}
        {% set w = (1 / ([diff, 0.5] | max)) * (1.0 + 0.3 * ([1.0 - days_ago / 30.0, 0.0] | max)) %}
        {% if yield_raw > 0.05 or clouds > 95 or current_month in [12, 1, 2] %}
          {% set ns_pool.total_w = ns_pool.total_w + w %}
          {% set ns_pool.items = ns_pool.items + [{'date': item.date, 'h_avg': clouds, 'y_korr': y_korr, 'w': w, 'days_ago': days_ago}] %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {# --- 5. FORECAST CALCULATION (Top 15, decision logic) --- #}
    {% set top15 = (ns_pool.items | sort(attribute='w', reverse=True))[:15] %}
    {% set ns_top = namespace(total_w=0) %}
    {% for item in top15 %}{% set ns_top.total_w = ns_top.total_w + item.w %}{% endfor %}
    {% set pool = top15 %}
    {% set brighter = pool | selectattr('h_avg', 'le', f_avg) | list %}
    {% set darker = pool | selectattr('h_avg', 'ge', f_avg) | list %}
    {% set res = 0 %}
    {% if brighter | count > 0 and darker | count == 0 %}
      {% set worst_day = brighter | sort(attribute='y_korr') | first %}
      {% set res = worst_day.y_korr * ([120 - f_avg, 5.0] | max / [120 - worst_day.h_avg, 5.0] | max) %}
    {% elif darker | count > 0 and pool | selectattr('h_avg', 'le', f_avg) | list | count == 0 %}
      {% set res = darker | map(attribute='y_korr') | max %}
    {% elif pool | count > 0 %}
      {% set ns_mix = namespace(ws=0) %}
      {% for item in pool %}{% set ns_mix.ws = ns_mix.ws + (item.y_korr * item.w) %}{% endfor %}
      {% set res = ns_mix.ws / (ns_top.total_w if ns_top.total_w > 0 else 1) %}
    {% endif %}

    {# --- 6. LOO CROSS-VALIDATION: down-weight outlier pool days --- #}
    {% set ns_cv = namespace(items=[]) %}
    {% for item_i in pool %}
      {% set ns_loo = namespace(w=0, wy=0) %}
      {% for item_j in pool %}
        {% if item_j.date != item_i.date %}
          {% set ns_loo.w  = ns_loo.w  + item_j.w %}
          {% set ns_loo.wy = ns_loo.wy + item_j.w * item_j.y_korr %}
        {% endif %}
      {% endfor %}
      {% set acc = ((item_i.y_korr / (ns_loo.wy / ns_loo.w)) * 100) | round(0) | int if (ns_loo.w > 0 and ns_loo.wy > 0) else 100 %}
      {% set ns_cv.items = ns_cv.items + [{'date': item_i.date, 'acc': acc}] %}
    {% endfor %}
    {% if pool | count > 1 %}
      {% set ns_corr = namespace(w=0, wy=0) %}
      {% for item_i in pool %}
        {% set cv = ns_cv.items | selectattr('date', 'equalto', item_i.date) | list %}
        {% set acc_factor = 1.0 / (1.0 + ((cv[0].acc - 100) | abs) / 100.0) if cv | length > 0 else 1.0 %}
        {% set ns_corr.w  = ns_corr.w  + item_i.w * acc_factor %}
        {% set ns_corr.wy = ns_corr.wy + item_i.y_korr * item_i.w * acc_factor %}
      {% endfor %}
      {% if ns_corr.w > 0 and ns_corr.wy > 0 %}{% set res = ns_corr.wy / ns_corr.w %}{% endif %}
    {% endif %}

    {# --- 7. TREND DAMPING: recent ≤14d avg >15% above older  dampen 50% of excess --- #}
    {% set ns_rec = namespace(w=0, wy=0) %}
    {% set ns_old = namespace(w=0, wy=0) %}
    {% for item_i in pool %}
      {% if item_i.days_ago <= 14 %}
        {% set ns_rec.w = ns_rec.w + item_i.w %}{% set ns_rec.wy = ns_rec.wy + item_i.y_korr * item_i.w %}
      {% else %}
        {% set ns_old.w = ns_old.w + item_i.w %}{% set ns_old.wy = ns_old.wy + item_i.y_korr * item_i.w %}
      {% endif %}
    {% endfor %}
    {% if ns_rec.w > 0 and ns_old.w > 0 %}
      {% set avg_rec = ns_rec.wy / ns_rec.w %}
      {% set avg_old = ns_old.wy / ns_old.w %}
      {% if avg_old > 0 and (avg_rec / avg_old) > 1.15 %}
        {% set res = res / (1.0 + 0.5 * ((avg_rec / avg_old) - 1.0)) %}
      {% endif %}
    {% endif %}

    {# --- 8. BACK-TEST: data-driven carry-through from consecutive shortfall pairs --- #}
    {% set ns_all = namespace(sum_y=0.0, count_y=0) %}
    {% for item in pool %}{% if item.y_korr > 0 %}{% set ns_all.sum_y = ns_all.sum_y + item.y_korr %}{% set ns_all.count_y = ns_all.count_y + 1 %}{% endif %}{% endfor %}
    {% set mean_y = ns_all.sum_y / ([ns_all.count_y, 1] | max) %}
    {% set ns_bt = namespace(total=0, useful=0, trigger_sum=0.0, carry_sum=0.0) %}
    {% for item_i in pool %}
      {% if item_i.y_korr >= 0.40 * mean_y and item_i.y_korr < 0.85 * mean_y %}
        {% set next_items = pool | selectattr('date', 'equalto', (as_datetime(item_i.date) + timedelta(days=1)).strftime('%Y-%m-%d')) | list %}
        {% if next_items | length > 0 %}
          {% set ns_bt.total = ns_bt.total + 1 %}
          {% set ns_bt.trigger_sum = ns_bt.trigger_sum + (1.0 - item_i.y_korr / mean_y) %}
          {% set ns_bt.carry_sum = ns_bt.carry_sum + ([1.0 - next_items[0].y_korr / mean_y, 0.0] | max) %}
          {% if next_items[0].y_korr < mean_y %}{% set ns_bt.useful = ns_bt.useful + 1 %}{% endif %}
        {% endif %}
      {% endif %}
    {% endfor %}
    {% set effective_carry = (ns_bt.carry_sum / ns_bt.trigger_sum) * (ns_bt.useful / ns_bt.total) if (ns_bt.total > 0 and ns_bt.trigger_sum > 0) else 0.3 %}

    {# --- 9. CLOUD-GATED YESTERDAY PENALTY (both days ≥60% cloudy) --- #}
    {% set yesterday_date_yp = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
    {% set yest_cv = ns_cv.items | selectattr('date', 'equalto', yesterday_date_yp) | list %}
    {% set yest_item = pool | selectattr('date', 'equalto', yesterday_date_yp) | list %}
    {% set yest_clouds = yest_item[0].h_avg if yest_item | length > 0 else 0 %}
    {% if yest_cv | length > 0 %}
      {% set yest_acc = yest_cv[0].acc %}
      {% if yest_acc >= 40 and yest_acc < 85 and f_avg >= 60 and yest_clouds >= 60 %}
        {% set res = res * ([1.0 - effective_carry * (1.0 - yest_acc / 100.0), 0.5] | max) %}
      {% endif %}
    {% endif %}

    {# --- 10. FINAL SCALING --- #}
    {{ (res * snow_factor_today) | round(2) }}

  {% endif %}
{% else %}
  0.0
{% endif %}

Erklärung der 10 Schritte:

  • 0. Nacht-Check: Nach lokalem Sonnenuntergang bis Mitternacht wird 0,0 ausgegeben; von Mitternacht bis Sonnenaufgang liefert SQL Ganztages-Daten.
  • 1–3. Basisdaten / Schnee / Astronomie: Monat, Schneefaktor (Dez–Feb), Tageslänge und relativer Sonnenstand werden berechnet.
  • 4. Pool-Aufbau mit dynamischem UV-Gewicht: uv_w = min(0.3 + 0.4 × Bewölkung/100, 0.7) – bei 0 % Bewölkung zählt UV zu 30 %, bei 100 % zu 70 %. Recency-Bonus bis +30 % für Tage <30 Tage.
  • 5. Entscheidungslogik (Top 15): Weighted average (Normalfall), Light reduction (heute heller als alle Referenztage), Max assumption (heute dunkler als alle Referenztage).
  • 6. LOO-Kreuzvalidierung: Jeder Pool-Tag wird gegen den Konsens der anderen validiert. Ausreißer werden mit acc_factor = 1 / (1 + |acc−100| / 100) heruntergewichtet.
  • 7. Trend-Dämpfung: Wenn die jüngsten 14 Tage mehr als 15 % über dem älteren Poolschnitt liegen, wird die Hälfte des Überschusses abgezogen.
  • 8. Back-Test: Aus aufeinanderfolgenden moderaten Mindertrag-Paaren im Pool wird ein datenbasierter Carry-Through-Faktor abgeleitet.
  • 9. Cloud-gated Penalty: Nur wenn gestern und heute ≥60 % Bewölkung, und der gestrige LOO-Acc im Bereich 40–84 % lag, wird der Carry-Through-Faktor auf die Prognose angewendet.
  • 10. Skalierung: Multiplikation mit Schneefaktor, Ausgabe in kWh.

Markdown-Lovelace-Card (Debugging-Tabelle)

Diese Lovelace-Markdown-Card zeigt alle Top-15-Vergleichstage mit Bewölkung, UV-Index, saisonalem Korrekturfaktor, LOO-Accuracy und Gewichtung – ideal um die Prognoseberechnung nachzuvollziehen:

[+]
{% set raw_json = state_attr('sensor.pv_remaining_states', 'json') %}
{% if raw_json and raw_json != '[]' and raw_json is not none %}
  {% set data = raw_json | from_json %}

  {% if data | length > 0 %}
    {% set f_avg = data[0].f_avg_today_remaining | float(default=50.0) %}
    {% set f_uv_avg = data[0].uv_avg_today_remaining | float(default=0.0) %}

    {# 0. NIGHT-CHECK #}
    {% set offset_min = (now().utcoffset().total_seconds() / 60) | int %}
    {% set pv_end_utc = data[0].pv_end | default('17:30') %}
    {% set end_min_local = ((pv_end_utc.split(':')[0] | int) * 60 + (pv_end_utc.split(':')[1] | int) + offset_min) % 1440 %}
    {% set is_night = (now().hour * 60 + now().minute) > end_min_local %}

    {# 1. SEASONAL SNOW DETECTION (Dec / Jan / Feb) #}
    {% set current_month = now().month %}
    {% set snow_factor_today = 1.0 %}
    {% if current_month in [12, 1, 2] %}
      {% set yesterday_date = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
      {% set yesterday_data = data | selectattr('date', 'equalto', yesterday_date) | list | first %}
      {% if yesterday_data is defined %}
        {% set yesterday_perf = yesterday_data.yield_day_remaining | float(0) / ([105 - yesterday_data.h_avg_remaining | float(0), 5] | max) %}
        {% if yesterday_perf < 0.02 %}{% set snow_factor_today = 0.1 %}{% endif %}
      {% endif %}
    {% endif %}

    {# 2. ASTRONOMICAL BASE DATA #}
    {% set latitude = state_attr('zone.home', 'latitude') | float(48.0) %}
    {% set doy = now().strftime('%j') | int(default=1) %}
    {% set lat_rad = latitude * pi / 180 %}
    {% set decl = -0.4093 * cos(2 * pi * (doy + 10) / 365) %}
    {% set dl_today = 24 / pi * acos([[(-tan(lat_rad) * tan(decl)), -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.80 + 0.20 * cos((doy - 172) * 2 * pi / 365) %}

    {# 3. POOL (dynamic UV weight, Recency-Bonus, Top 15) #}
    {% set uv_w = [0.3 + 0.4 * (f_avg / 100.0), 0.7] | min %}
    {% set ns_pool = namespace(items=[], total_w=0) %}
    {% for item in data %}
      {% set yield_raw = item.yield_day_remaining | float(default=0) %}
      {% set clouds = item.h_avg_remaining | float(default=0) %}
      {% set clouds_total = item.h_avg_total | float(default=0) %}
      {% set uv = item.uv_avg_remaining | float(default=0) %}
      {% set item_dt = as_datetime(item.date) %}
      {% if item_dt is not none %}
        {% set item_day = item_dt.strftime('%j') | int(default=1) %}
        {% set decl_i = -0.4093 * cos(2 * pi * (item_day + 10) / 365) %}
        {% set dl_item = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_i)), -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.80 + 0.20 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set diff_c = (clouds - f_avg) | abs %}
        {% if f_uv_avg > 0 %}
          {% set diff = diff_c * (1.0 - uv_w) + (uv - f_uv_avg) | abs * 8.0 * uv_w %}
        {% else %}
          {% set diff = diff_c %}
        {% endif %}
        {% set days_ago = ((now().timestamp() - item_dt.timestamp()) / 86400) | int(0) %}
        {% set w = (1 / ([diff, 0.5] | max)) * (1.0 + 0.3 * ([1.0 - days_ago / 30.0, 0.0] | max)) %}
        {% if yield_raw > 0.05 or clouds > 95 or current_month in [12, 1, 2] %}
          {% set ns_pool.total_w = ns_pool.total_w + w %}
          {% set ns_pool.items = ns_pool.items + [{'date': item.date, 'h_avg': clouds, 'h_avg_total': clouds_total, 'uv_avg': uv, 'y_korr': yield_raw * s_korr, 's_fakt': s_korr, 'w': w, 'yield_day_total': item.yield_day_total, 'days_ago': days_ago, 'filtered': false}] %}
        {% else %}
          {% set ns_pool.items = ns_pool.items + [{'date': item.date, 'h_avg': clouds, 'h_avg_total': clouds_total, 'uv_avg': uv, 'y_korr': yield_raw * s_korr, 's_fakt': s_korr, 'w': 0, 'yield_day_total': item.yield_day_total, 'days_ago': days_ago, 'filtered': true}] %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {# Top 15 selection #}
    {% set top15 = (ns_pool.items | sort(attribute='w', reverse=True))[:15] %}
    {% set ns_top = namespace(total_w=0) %}
    {% for item in top15 %}{% if not item.filtered %}{% set ns_top.total_w = ns_top.total_w + item.w %}{% endif %}{% endfor %}
    {% set pool = top15 | selectattr('filtered', 'equalto', false) | list %}

    {# 4. Decision logic #}
    {% set brighter = pool | selectattr('h_avg', 'le', f_avg) | list %}
    {% set darker = pool | selectattr('h_avg', 'ge', f_avg) | list %}
    {% set res = 0 %}
    {% set method = "No data" %}
    {% if brighter | count > 0 and darker | count == 0 %}
      {% set method = "Light reduction" %}
      {% set worst_day = brighter | sort(attribute='y_korr') | first %}
      {% set res = worst_day.y_korr * ([120 - f_avg, 5.0] | max / [120 - worst_day.h_avg, 5.0] | max) %}
    {% elif darker | count > 0 and pool | selectattr('h_avg', 'le', f_avg) | list | count == 0 %}
      {% set method = "Max assumption" %}
      {% set res = darker | map(attribute='y_korr') | max %}
    {% elif pool | count > 0 %}
      {% set method = "Weighted average" %}
      {% set ns_mix = namespace(ws=0) %}
      {% for item in pool %}{% set ns_mix.ws = ns_mix.ws + (item.y_korr * item.w) %}{% endfor %}
      {% set res = ns_mix.ws / (ns_top.total_w if ns_top.total_w > 0 else 1) %}
    {% endif %}

    {# 5. LOO cross-validation #}
    {% set ns_cv = namespace(items=[]) %}
    {% for item_i in pool %}
      {% set ns_loo = namespace(w=0, wy=0) %}
      {% for item_j in pool %}
        {% if item_j.date != item_i.date %}
          {% set ns_loo.w = ns_loo.w + item_j.w %}{% set ns_loo.wy = ns_loo.wy + item_j.w * item_j.y_korr %}
        {% endif %}
      {% endfor %}
      {% set acc = ((item_i.y_korr / (ns_loo.wy / ns_loo.w)) * 100) | round(0) | int if (ns_loo.w > 0 and ns_loo.wy > 0) else 100 %}
      {% set ns_cv.items = ns_cv.items + [{'date': item_i.date, 'acc': acc}] %}
    {% endfor %}
    {% if pool | count > 1 %}
      {% set ns_corr = namespace(w=0, wy=0) %}
      {% for item_i in pool %}
        {% set cv = ns_cv.items | selectattr('date', 'equalto', item_i.date) | list %}
        {% set acc_factor = 1.0 / (1.0 + ((cv[0].acc - 100) | abs) / 100.0) if cv | length > 0 else 1.0 %}
        {% set ns_corr.w = ns_corr.w + item_i.w * acc_factor %}{% set ns_corr.wy = ns_corr.wy + item_i.y_korr * item_i.w * acc_factor %}
      {% endfor %}
      {% if ns_corr.w > 0 and ns_corr.wy > 0 %}{% set res = ns_corr.wy / ns_corr.w %}{% endif %}
    {% endif %}

    {# 6. Trend damping #}
    {% set ns_rec = namespace(w=0, wy=0) %}{% set ns_old = namespace(w=0, wy=0) %}
    {% for item_i in pool %}
      {% if item_i.days_ago <= 14 %}{% set ns_rec.w = ns_rec.w + item_i.w %}{% set ns_rec.wy = ns_rec.wy + item_i.y_korr * item_i.w %}
      {% else %}{% set ns_old.w = ns_old.w + item_i.w %}{% set ns_old.wy = ns_old.wy + item_i.y_korr * item_i.w %}{% endif %}
    {% endfor %}
    {% set trend_damped = false %}
    {% if ns_rec.w > 0 and ns_old.w > 0 %}
      {% set avg_rec = ns_rec.wy / ns_rec.w %}{% set avg_old = ns_old.wy / ns_old.w %}
      {% if avg_old > 0 and (avg_rec / avg_old) > 1.15 %}
        {% set res = res / (1.0 + 0.5 * ((avg_rec / avg_old) - 1.0)) %}{% set trend_damped = true %}
      {% endif %}
    {% endif %}

    {# 7. Back-test + Cloud-gated penalty #}
    {% set ns_all = namespace(sum_y=0.0, count_y=0) %}
    {% for item in pool %}{% if item.y_korr > 0 %}{% set ns_all.sum_y = ns_all.sum_y + item.y_korr %}{% set ns_all.count_y = ns_all.count_y + 1 %}{% endif %}{% endfor %}
    {% set mean_y = ns_all.sum_y / ([ns_all.count_y, 1] | max) %}
    {% set ns_bt = namespace(total=0, useful=0, trigger_sum=0.0, carry_sum=0.0) %}
    {% for item_i in pool %}
      {% if item_i.y_korr >= 0.40 * mean_y and item_i.y_korr < 0.85 * mean_y %}
        {% set next_items = pool | selectattr('date', 'equalto', (as_datetime(item_i.date) + timedelta(days=1)).strftime('%Y-%m-%d')) | list %}
        {% if next_items | length > 0 %}
          {% set ns_bt.total = ns_bt.total + 1 %}{% set ns_bt.trigger_sum = ns_bt.trigger_sum + (1.0 - item_i.y_korr / mean_y) %}
          {% set ns_bt.carry_sum = ns_bt.carry_sum + ([1.0 - next_items[0].y_korr / mean_y, 0.0] | max) %}
          {% if next_items[0].y_korr < mean_y %}{% set ns_bt.useful = ns_bt.useful + 1 %}{% endif %}
        {% endif %}
      {% endif %}
    {% endfor %}
    {% set effective_carry = (ns_bt.carry_sum / ns_bt.trigger_sum) * (ns_bt.useful / ns_bt.total) if (ns_bt.total > 0 and ns_bt.trigger_sum > 0) else 0.3 %}
    {% set yest_date = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
    {% set yest_cv = ns_cv.items | selectattr('date', 'equalto', yest_date) | list %}
    {% set yest_item = pool | selectattr('date', 'equalto', yest_date) | list %}
    {% set yest_clouds = yest_item[0].h_avg if yest_item | length > 0 else 0 %}
    {% set penalty_applied = false %}
    {% if yest_cv | length > 0 %}
      {% set yest_acc = yest_cv[0].acc %}
      {% if yest_acc >= 40 and yest_acc < 85 and f_avg >= 60 and yest_clouds >= 60 %}
        {% set res = res * ([1.0 - effective_carry * (1.0 - yest_acc / 100.0), 0.5] | max) %}
        {% set penalty_applied = true %}
      {% endif %}
    {% endif %}

    {% set final_val = res * snow_factor_today %}

**Forecast:**
## {{ (0.0 if is_night else final_val | round(2)) }} kWh
*Basis: **{{ f_avg }}%** clouds · **{{ f_uv_avg | round(1) }}** UV · uv_w={{ (uv_w * 100) | round(0) | int }}% | **{{ method }}***
{% if snow_factor_today < 1.0 %}⚠️ **Schnee erkannt! (Faktor {{ (snow_factor_today * 100) | round(0) }}%)**{% endif %}
{% if trend_damped %}📉 Trend-Dämpfung aktiv{% endif %}
{% if penalty_applied %}☁️ Cloud-gated Penalty aktiv (carry={{ (effective_carry * 100) | round(0) | int }}%){% endif %}

| Datum | Tag-Bew. | Tag-Ertrag | Rest-Bew. | UV | Rest-Ertrag | LOO-Acc | Gewicht |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
{%- for item in top15 %}
  {%- set cv = ns_cv.items | selectattr('date', 'equalto', item.date) | list %}
  {%- set acc_str = cv[0].acc | string + "%" if cv | length > 0 else "–" %}
| {{ item.date }} | {{ item.h_avg_total }}% | {{ item.yield_day_total }} | **{{ item.h_avg }}%** | {{ item.uv_avg | round(1) }} | **{{ (item.y_korr * snow_factor_today) | round(2) }} ({{ item.s_fakt | round(2) }}×)**{% if item.filtered %}❌{% endif %} | {{ acc_str }} | {{ (((item.w / ns_top.total_w) * 100) if ns_top.total_w > 0 else 0) | round(1) }}% |
{%- endfor %}

  {% else %}
**Keine Daten im SQL-Ergebnis.**
  {% endif %}
{% else %}
**Warte auf SQL-Daten...**
{% endif %}

Morgen-Sensor

Vorhersage für den morgigen Tag: Helfer-Template-Sensor (dynamisches UV-Gewicht, LOO, Trend-Dämpfung, Back-Test, Cloud-gated Penalty):

[+]
{# PV FORECAST TOMORROW: weighted avg + LOO + trend damping + back-test + cloud-gated penalty #}
{% set raw_json = state_attr('sensor.pv_remaining_states', 'json') %}

{% if raw_json and raw_json != '[]' and raw_json is not none %}
  {% set data = raw_json | from_json %}

  {% set f_avg_tomorrow = data[0].f_avg_tomorrow | float(default=50.0) %}
  {% set f_uv_avg_tomorrow = data[0].uv_avg_tomorrow | float(default=0.0) %}

  {# ASTRONOMICAL BASE DATA FOR TOMORROW #}
  {% set latitude = latitude if latitude is defined else state_attr('zone.home', 'latitude') | float(48.0) %}
  {% set doy_tomorrow = (now() + timedelta(days=1)).strftime('%j') | int %}
  {% set lat_rad = latitude * pi / 180 %}
  {% set decl_tomorrow = -0.4093 * cos(2 * pi * (doy_tomorrow + 10) / 365) %}
  {% set dl_tomorrow = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_tomorrow)), -1.0] | max, 1.0] | min) %}
  {% set sun_tomorrow = 0.80 + 0.20 * cos((doy_tomorrow - 172) * 2 * pi / 365) %}

  {# POOL: dynamic UV weight, Recency-Bonus, Top 15 #}
  {% set uv_w = [0.3 + 0.4 * (f_avg_tomorrow / 100.0), 0.7] | min %}
  {% set ns_pool = namespace(items=[], total_w=0) %}
  {% for item in data %}
    {% set yield_total = item.yield_day_total | float(default=0) %}
    {% set clouds_hist = item.h_avg_total | float(default=0) %}
    {% set uv_hist = item.uv_avg_total | float(default=0) %}
    {% set dt_item = as_datetime(item.date) %}
    {% if dt_item is not none %}
      {% set item_day = dt_item.strftime('%j') | int(default=1) %}
      {% set decl_i = -0.4093 * cos(2 * pi * (item_day + 10) / 365) %}
      {% set dl_item = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_i)), -1.0] | max, 1.0] | min) %}
      {% set sun_item = 0.80 + 0.20 * cos((item_day - 172) * 2 * pi / 365) %}
      {% set s_korr = (sun_tomorrow / sun_item) * (dl_tomorrow / dl_item) %}
      {% set y_korr = yield_total * s_korr %}
      {% set diff_c = (clouds_hist - f_avg_tomorrow) | abs %}
      {% if f_uv_avg_tomorrow > 0 %}
        {% set diff = diff_c * (1.0 - uv_w) + (uv_hist - f_uv_avg_tomorrow) | abs * 8.0 * uv_w %}
      {% else %}
        {% set diff = diff_c %}
      {% endif %}
      {% set days_ago = ((now().timestamp() - dt_item.timestamp()) / 86400) | int(0) %}
      {% set w = (1 / ([diff, 0.5] | max)) * (1.0 + 0.3 * ([1.0 - days_ago / 30.0, 0.0] | max)) %}
      {% if yield_total > 0.05 %}
        {% set ns_pool.total_w = ns_pool.total_w + w %}
        {% set ns_pool.items = ns_pool.items + [{'date': item.date, 'h_avg': clouds_hist, 'w': w, 'y_korr': y_korr, 'days_ago': days_ago, 'filtered': false}] %}
      {% else %}
        {% set ns_pool.items = ns_pool.items + [{'date': item.date, 'h_avg': clouds_hist, 'w': 0, 'y_korr': y_korr, 'days_ago': days_ago, 'filtered': true}] %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% set top15 = (ns_pool.items | sort(attribute='w', reverse=True))[:15] %}
  {% set ns_top = namespace(total_w=0) %}
  {% for item in top15 %}{% if not item.filtered %}{% set ns_top.total_w = ns_top.total_w + item.w %}{% endif %}{% endfor %}
  {% set pool = top15 | selectattr('filtered', 'equalto', false) | list %}
  {% set brighter = pool | selectattr('h_avg', 'le', f_avg_tomorrow) | list %}
  {% set darker = pool | selectattr('h_avg', 'ge', f_avg_tomorrow) | list %}
  {% set res = 0 %}
  {% if brighter | count > 0 and darker | count == 0 %}
    {% set worst_day = brighter | sort(attribute='y_korr') | first %}
    {% set res = worst_day.y_korr * ([120 - f_avg_tomorrow, 5.0] | max / [120 - worst_day.h_avg, 5.0] | max) %}
  {% elif darker | count > 0 and pool | selectattr('h_avg', 'le', f_avg_tomorrow) | list | count == 0 %}
    {% set res = darker | map(attribute='y_korr') | max %}
  {% elif pool | count > 0 %}
    {% set ns_mix = namespace(ws=0) %}
    {% for item in pool %}{% set ns_mix.ws = ns_mix.ws + (item.y_korr * item.w) %}{% endfor %}
    {% set res = ns_mix.ws / (ns_top.total_w if ns_top.total_w > 0 else 1) %}
  {% endif %}

  {# LOO CROSS-VALIDATION #}
  {% set ns_cv = namespace(items=[]) %}
  {% for item_i in pool %}
    {% set ns_loo = namespace(w=0, wy=0) %}
    {% for item_j in pool %}
      {% if item_j.date != item_i.date %}{% set ns_loo.w = ns_loo.w + item_j.w %}{% set ns_loo.wy = ns_loo.wy + item_j.w * item_j.y_korr %}{% endif %}
    {% endfor %}
    {% set acc = ((item_i.y_korr / (ns_loo.wy / ns_loo.w)) * 100) | round(0) | int if (ns_loo.w > 0 and ns_loo.wy > 0) else 100 %}
    {% set ns_cv.items = ns_cv.items + [{'date': item_i.date, 'acc': acc}] %}
  {% endfor %}
  {% if pool | count > 1 %}
    {% set ns_corr = namespace(w=0, wy=0) %}
    {% for item_i in pool %}
      {% set cv = ns_cv.items | selectattr('date', 'equalto', item_i.date) | list %}
      {% set acc_factor = 1.0 / (1.0 + ((cv[0].acc - 100) | abs) / 100.0) if cv | length > 0 else 1.0 %}
      {% set ns_corr.w = ns_corr.w + item_i.w * acc_factor %}{% set ns_corr.wy = ns_corr.wy + item_i.y_korr * item_i.w * acc_factor %}
    {% endfor %}
    {% if ns_corr.w > 0 and ns_corr.wy > 0 %}{% set res = ns_corr.wy / ns_corr.w %}{% endif %}
  {% endif %}

  {# TREND DAMPING #}
  {% set ns_rec = namespace(w=0, wy=0) %}{% set ns_old = namespace(w=0, wy=0) %}
  {% for item_i in pool %}
    {% if item_i.days_ago <= 14 %}{% set ns_rec.w = ns_rec.w + item_i.w %}{% set ns_rec.wy = ns_rec.wy + item_i.y_korr * item_i.w %}
    {% else %}{% set ns_old.w = ns_old.w + item_i.w %}{% set ns_old.wy = ns_old.wy + item_i.y_korr * item_i.w %}{% endif %}
  {% endfor %}
  {% if ns_rec.w > 0 and ns_old.w > 0 %}
    {% set avg_rec = ns_rec.wy / ns_rec.w %}{% set avg_old = ns_old.wy / ns_old.w %}
    {% if avg_old > 0 and (avg_rec / avg_old) > 1.15 %}
      {% set res = res / (1.0 + 0.5 * ((avg_rec / avg_old) - 1.0)) %}
    {% endif %}
  {% endif %}

  {# BACK-TEST + CLOUD-GATED YESTERDAY PENALTY #}
  {% set ns_all = namespace(sum_y=0.0, count_y=0) %}
  {% for item in pool %}{% if item.y_korr > 0 %}{% set ns_all.sum_y = ns_all.sum_y + item.y_korr %}{% set ns_all.count_y = ns_all.count_y + 1 %}{% endif %}{% endfor %}
  {% set mean_y = ns_all.sum_y / ([ns_all.count_y, 1] | max) %}
  {% set ns_bt = namespace(total=0, useful=0, trigger_sum=0.0, carry_sum=0.0) %}
  {% for item_i in pool %}
    {% if item_i.y_korr >= 0.40 * mean_y and item_i.y_korr < 0.85 * mean_y %}
      {% set next_items = pool | selectattr('date', 'equalto', (as_datetime(item_i.date) + timedelta(days=1)).strftime('%Y-%m-%d')) | list %}
      {% if next_items | length > 0 %}
        {% set ns_bt.total = ns_bt.total + 1 %}{% set ns_bt.trigger_sum = ns_bt.trigger_sum + (1.0 - item_i.y_korr / mean_y) %}
        {% set ns_bt.carry_sum = ns_bt.carry_sum + ([1.0 - next_items[0].y_korr / mean_y, 0.0] | max) %}
        {% if next_items[0].y_korr < mean_y %}{% set ns_bt.useful = ns_bt.useful + 1 %}{% endif %}
      {% endif %}
    {% endif %}
  {% endfor %}
  {% set effective_carry = (ns_bt.carry_sum / ns_bt.trigger_sum) * (ns_bt.useful / ns_bt.total) if (ns_bt.total > 0 and ns_bt.trigger_sum > 0) else 0.3 %}
  {% set yesterday_date = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
  {% set yest_cv = ns_cv.items | selectattr('date', 'equalto', yesterday_date) | list %}
  {% set yest_item = ns_pool.items | selectattr('date', 'equalto', yesterday_date) | selectattr('filtered', 'equalto', false) | list %}
  {% set yest_clouds = yest_item[0].h_avg if yest_item | length > 0 else 0 %}
  {% if yest_cv | length > 0 %}
    {% set yest_acc = yest_cv[0].acc %}
    {% if yest_acc >= 40 and yest_acc < 85 and f_avg_tomorrow >= 60 and yest_clouds >= 60 %}
      {% set res = res * ([1.0 - effective_carry * (1.0 - yest_acc / 100.0), 0.5] | max) %}
    {% endif %}
  {% endif %}

  {{ res | round(2) }}

{% else %}
  0.0
{% endif %}

Optimistischer Sensor (max)

Gibt den höchsten korrigierten Ertrag der 15 ähnlichsten Tage zurück – obere Grenze der Prognose (kein LOO/Penalty, da hier Oberkante gewünscht ist):

[+]
{% set raw_json = state_attr('sensor.pv_remaining_states', 'json') %}
{% if raw_json and raw_json != '[]' and raw_json is not none %}
  {% set data = raw_json | from_json %}
  {# Nacht-Check #}
  {% set offset_min = (now().utcoffset().total_seconds() / 60) | int %}
  {% set pv_end_utc = data[0].pv_end | default('17:30') %}
  {% set end_min_local = ((pv_end_utc.split(':')[0] | int) * 60 + (pv_end_utc.split(':')[1] | int) + offset_min) % 1440 %}
  {% if (now().hour * 60 + now().minute) > end_min_local %}
    0.0
  {% else %}
    {% set f_avg = data[0].f_avg_today_remaining | float(default=50.0) %}
    {% set f_uv_avg = data[0].uv_avg_today_remaining | float(default=0.0) %}
    {% set doy = now().strftime('%j') | int(default=1) %}
    {% set latitude = latitude if latitude is defined else state_attr('zone.home', 'latitude') | float(48.0) %}
    {% set lat_rad = latitude * pi / 180 %}
    {% set decl = -0.4093 * cos(2 * pi * (doy + 10) / 365) %}
    {% set dl_today = 24 / pi * acos([[(-tan(lat_rad) * tan(decl)), -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.80 + 0.20 * cos((doy - 172) * 2 * pi / 365) %}
    {% set uv_w = [0.3 + 0.4 * (f_avg / 100.0), 0.7] | min %}
    {% set ns_pool = namespace(items=[]) %}
    {% for item in data %}
      {% set dt_item = as_datetime(item.date) %}
      {% if dt_item is not none %}
        {% set item_day = dt_item.strftime('%j') | int(default=1) %}
        {% set decl_i = -0.4093 * cos(2 * pi * (item_day + 10) / 365) %}
        {% set dl_item = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_i)), -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.80 + 0.20 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set yield_korr = item.yield_day_remaining | float(default=0) * s_korr %}
        {% set uv_hist = item.uv_avg_remaining | float(default=0) %}
        {% set diff_c = (item.h_avg_remaining | float(default=0) - f_avg) | abs %}
        {% if f_uv_avg > 0 %}
          {% set diff = diff_c * (1.0 - uv_w) + (uv_hist - f_uv_avg) | abs * 8.0 * uv_w %}
        {% else %}
          {% set diff = diff_c %}
        {% endif %}
        {% set ns_pool.items = ns_pool.items + [{'diff': diff, 'y_korr': yield_korr}] %}
      {% endif %}
    {% endfor %}
    {% set top15 = (ns_pool.items | sort(attribute='diff'))[:15] %}
    {% set max_yield = top15 | map(attribute='y_korr') | max if top15 | count > 0 else 0 %}
    {{ max_yield | round(2) }}
  {% endif %}
{% else %}
  0
{% endif %}

Pessimistischer Sensor (min)

Gibt den niedrigsten korrigierten Ertrag der 15 ähnlichsten Tage zurück – untere Grenze der Prognose (kein LOO/Penalty, da hier Unterkante gewünscht ist):

[+]
{% set raw_json = state_attr('sensor.pv_remaining_states', 'json') %}
{% if raw_json and raw_json != '[]' and raw_json is not none %}
  {% set data = raw_json | from_json %}
  {# Nacht-Check #}
  {% set offset_min = (now().utcoffset().total_seconds() / 60) | int %}
  {% set pv_end_utc = data[0].pv_end | default('17:30') %}
  {% set end_min_local = ((pv_end_utc.split(':')[0] | int) * 60 + (pv_end_utc.split(':')[1] | int) + offset_min) % 1440 %}
  {% if (now().hour * 60 + now().minute) > end_min_local %}
    0.0
  {% else %}
    {% set f_avg = data[0].f_avg_today_remaining | float(default=50.0) %}
    {% set f_uv_avg = data[0].uv_avg_today_remaining | float(default=0.0) %}
    {% set current_month = now().month %}
    {% set doy = now().strftime('%j') | int(default=1) %}
    {% set latitude = latitude if latitude is defined else state_attr('zone.home', 'latitude') | float(48.0) %}
    {% set lat_rad = latitude * pi / 180 %}
    {% set decl = -0.4093 * cos(2 * pi * (doy + 10) / 365) %}
    {% set dl_today = 24 / pi * acos([[(-tan(lat_rad) * tan(decl)), -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.80 + 0.20 * cos((doy - 172) * 2 * pi / 365) %}
    {% set uv_w = [0.3 + 0.4 * (f_avg / 100.0), 0.7] | min %}
    {% set ns_pool = namespace(items=[]) %}
    {% for item in data %}
      {% set dt_item = as_datetime(item.date) %}
      {% if dt_item is not none %}
        {% set item_day = dt_item.strftime('%j') | int(default=1) %}
        {% set decl_i = -0.4093 * cos(2 * pi * (item_day + 10) / 365) %}
        {% set dl_item = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_i)), -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.80 + 0.20 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set yield_korr = item.yield_day_remaining | float(default=0) * s_korr %}
        {% set uv_hist = item.uv_avg_remaining | float(default=0) %}
        {% set diff_c = (item.h_avg_remaining | float(default=0) - f_avg) | abs %}
        {% if f_uv_avg > 0 %}
          {% set diff = diff_c * (1.0 - uv_w) + (uv_hist - f_uv_avg) | abs * 8.0 * uv_w %}
        {% else %}
          {% set diff = diff_c %}
        {% endif %}
        {% set ns_pool.items = ns_pool.items + [{'diff': diff, 'h_avg': item.h_avg_remaining | float(0), 'y_korr': yield_korr}] %}
      {% endif %}
    {% endfor %}
    {% set top15 = (ns_pool.items | sort(attribute='diff'))[:15] %}
    {% set brighter = top15 | selectattr('h_avg', 'le', f_avg) | list %}
    {% set darker = top15 | selectattr('h_avg', 'gt', f_avg) | list %}
    {% set res = 0 %}
    {% if top15 | count > 0 %}
      {% if brighter | count > 0 and darker | count == 0 %}
        {% set worst = brighter | sort(attribute='y_korr') | first %}
        {% set res = worst.y_korr * ([120 - f_avg, 5.0] | max / [120 - worst.h_avg, 5.0] | max) %}
      {% elif darker | count > 0 and brighter | count == 0 %}
        {% set res = darker | map(attribute='y_korr') | min %}
      {% else %}
        {% set res = top15 | map(attribute='y_korr') | min %}
      {% endif %}
    {% endif %}
    {{ res | round(2) }}
  {% endif %}
{% else %}
  0
{% endif %}

Das Ergebnis

Du erhältst keine theoretische Schätzung, sondern eine Prognose, die auf der realen Performance deiner Hardware unter deinen individuellen Standortbedingungen basiert.
Vorteil: Je länger das System läuft, desto präziser wird es, da der "Pool" an historischen Zwillingstagen stetig wächst.

Fazit

PV History Forecast ist die smarte Alternative zu externen Prognosediensten für alle, die Home Assistant nutzen und bereits über historische PV-Ertragsdaten verfügen. Die Integration lernt aus der Vergangenheit der eigenen Anlage und liefert dadurch eine deutlich anlagenspezifischere Restprognose – ohne externe API, ohne Registrierung, direkt aus der eigenen HA-Datenbank.

positive Bewertung({{pro_count}})
Beitrag bewerten:
{{percentage}} % positiv
negative Bewertung({{con_count}})

DANKE für deine Bewertung!

Beitrag erstellt von Bernhard | Veröffentlicht: 24.03.2026 | Aktualisiert: 22.04.2026 | Translation English |🔔 | Kommentare:0

Fragen / Kommentare


 
Durch die weitere Nutzung der Seite stimmst du der Verwendung von Cookies zu Mehr Details