PV Vorhersage: rein mit Wetter und historischen Werten

Preview PV Vorhersage: rein mit Wetter und historischen Werten

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.

Beta: HACS Integration

PV History Forecast ist eine Custom Integration für Home Assistant (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.
Installation: HACS: Benutzerdefiniertes Repository
Typ: Integration
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)
  • Gewichtet – je mehr ein Vergleichstag der heutigen Bewölkung ähnelt, desto stärker geht er in die Prognose ein
  • Gemittelt oder interpoliert – je nach verfügbarer Datenlage (gewichteter Mittelwert, Licht-Reduktion oder Max-Annahme)
Das Ergebnis: Eine Restprognose für heute, die auf echten Daten der eigenen Anlage basiert.

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_min Pessimistischer Tagesrest (wolkigere ähnliche Tage)
sensor.pv_hist_remaining_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 Automatischer Bewölkungssensor (wenn kein externer Sensor gewählt)

Der Hauptsensor `sensor.pv_hist_remaining_today` enthält zusätzlich das Attribut `lovelace_card` – eine fertig gerenderte Markdown-Karte, die direkt im Dashboard eingebunden werden kann.

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 = Auto-Sensor wird 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 fertige Markdown-Karte ist als Attribut des Hauptsensors verfügbar und zeigt

  • 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:

type: markdown
content: "{{ state_attr('sensor.pv_hist_remaining_today', 'lovelace_card') }}"

Voraussetzungen

  • Home Assistant 2024.1.0 oder neuer
    HACS installiert und eingerichtet
    PV-Sensor: `state_class: total_increasing`, Einheit `kWh` oder `Wh`, aktive Statistiken
    Bewölkungsdaten: Wetter-Entity mit `cloud_coverage` in der Forecast-Antwort (oder externer Bewölkungssensor)

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.home",'cloud_coverage') | float}}

SQL-Sensor mit Wert Template

für das Anlegen des SQL-Sensors benöitgen wir lediglich 3 Sensoren für die Quelldaten:

  • weather.forecast_home enthält die historischen Bewölkungsgrade (Standard-Sensor, nach dem Einrichten von Home Assistant.
  • sensor.weather_forecast_hourly die Vorhersage (Voraussetzung: Anlage in configuration.yaml wie oben beschrieben)
  • sensor.pv_panels_energy: Gesamtzähler für den PV-Ertrag.

Die Namen können entsprechend zu Beginn des SQL-Sensors angepasst werden: 

[+]
WITH vars AS (
    SELECT 
        'weather.forecast_home' as sensor_clouds,   -- Weather Entity direkt: cloud_coverage 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_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
),

pv_activity AS (
    /* Ermittelt die Sonnenauf- und Untergangszeiten basierend auf der gestrigen PV-Produktion */
    SELECT 
        COALESCE((
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch') 
            FROM states 
            WHERE metadata_id = (SELECT p_id_states FROM ids) 
              AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day') 
              AND state NOT IN ('unknown','0','0.0','unavailable') 
            ORDER BY last_updated_ts ASC LIMIT 1
        ), '05:30') as sun_start,
        COALESCE((
            -- Letzter Zeitpunkt, an dem der kumulative Sensor noch UNTER seinem Tagesmaximum lag
            -- = letzter aktiver Produktionszeitpunkt (Sonnenuntergang).
            -- state DESC LIMIT 1 wäre falsch: kumulativer Sensor bleibt bis Mitternacht auf Max-Wert.
            SELECT strftime('%H:%M', last_updated_ts, 'unixepoch') 
            FROM states 
            WHERE metadata_id = (SELECT p_id_states FROM ids) 
              AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day') 
              AND state NOT IN ('unknown', 'unavailable', '')
              AND CAST(state AS FLOAT) < (
                  SELECT MAX(CAST(state AS FLOAT))
                  FROM states
                  WHERE metadata_id = (SELECT p_id_states FROM ids)
                    AND date(last_updated_ts, 'unixepoch', (SELECT offset FROM vars)) = date('now', (SELECT offset FROM vars), '-1 day')
                    AND state NOT IN ('unknown', 'unavailable', '')
              )
            ORDER BY last_updated_ts DESC LIMIT 1
        ), '17:30') as sun_end
    FROM ids
),

forecast_val AS (
    /* Berechnet die durchschnittliche Bewölkung 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)) 
           -- Abgleich des Forecast-Datums mit dem lokalen "Heute" (via 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 
                         WHEN strftime('%H:%M', 'now') > (SELECT sun_start 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
),

forecast_next_day AS (
    /* Berechnet die durchschnittliche Bewölkung 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_morgen
),

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', '')
),

matching_days AS (
    /* Findet vergangene Tage, deren Bewölkungsprofil dem heutigen Forecast am nächsten kommt */
    SELECT 
        date(ts, 'unixepoch') as day, 
        AVG(CASE WHEN strftime('%H:%M', ts, 'unixepoch') BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity) THEN val END) as h_avg_total_val,
        AVG(CASE WHEN strftime('%H:%M', ts, 'unixepoch') >= strftime('%H:00', 'now') AND strftime('%H:%M', ts, 'unixepoch') <= (SELECT sun_end FROM pv_activity) THEN val END) as h_avg_rest_val
    FROM cloud_history 
    -- Filtert die Historie: Alles vor dem heutigen lokalen Tag (Offset-gesteuert)
    WHERE date(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(
        'datum', day,
        'f_avg_heute_rest', (SELECT ROUND(f_avg, 1) FROM forecast_val),        
        'f_avg_morgen', (SELECT ROUND(f_avg_morgen, 1) FROM forecast_next_day),
        'h_avg_gesamt', ROUND(h_avg_total_val, 1),
        'h_avg_rest', ROUND(h_avg_rest_val, 1),
        'ertrag_tag_gesamt', ROUND(day_max - day_min, 2),
        -- Ertrag 0 nur wenn UTC-Jetztzeit ZWISCHEN pv_ende und lokalem Mitternacht (UTC).
        -- Vermeidet Fehlwert 0 in der Stunde nach lokalem Mitternacht (UTC 22-24 Uhr bei MEZ/MESZ),
        -- da '23:30' > '17:30' im Stringvergleich fälschlicherweise TRUE ergibt.
        -- Return 0 whenever the current UTC time is outside the PV-active window.
        -- sun_start and sun_end are stored in UTC (derived from HA state timestamps).
        -- This correctly covers all nighttime hours including 23:00-00:00 UTC
        -- (= 00:00-01:00 local CET), which the previous BETWEEN guard missed.
        'ertrag_tag_rest', ROUND(CASE 
            WHEN NOT (strftime('%H:%M', 'now') BETWEEN (SELECT sun_start FROM pv_activity) AND (SELECT sun_end FROM pv_activity))
                THEN 0.0
            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_ende', (SELECT sun_end FROM pv_activity)
    )
) as json FROM final_data WHERE day_max > 0;

Solltest du wie vorhin präsentiert 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, mit dem Template-Sensor ersetzt werden:      

...
 'sensor.weather_cloud_coverage' as sensor_clouds,
...

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

Die SQL-Abfrage in Home Assistant hat eine einzige Aufgabe: Sie durchforstet die letzten 60 Tage deiner Datenbank nach Tagen, die wettertechnisch „Zwillinge“ von heute sind.
Sonnen-Fenster (pv_activity): SQL schaut sich an, wann deine Anlage gestern das erste und das letzte Mal Strom geliefert hat. Nur in diesem Zeitraum (z.B. 06:30 bis 18:30 Uhr) wird die Bewölkung verglichen.
Der Bewölkungs-Vergleich (Matching): Die Abfrage nimmt den Wetterbericht für heute (z.B. 40% Wolken) und sortiert alle vergangenen Tage danach, wie nah sie an diesen 40% lagen. Die Tage mit der geringsten Abweichung landen ganz oben in einer Liste (JSON).

Wert-Template

[+]
{# PV-PROGNOSE LOGIK: Berechnet den Rest-Ertrag basierend auf historisch ähnlichen Tagen #}
{% set raw = value %}

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

    {# --- 1. BASIS-DATEN --- #}
    {% set f_avg = data[0].f_avg_heute_rest | float(default=50.0) %}
    {% set current_month = now().month %}
    {% set schnee_faktor_heute = 1.0 %}

    {# --- 2. SAISONALE SCHNEE-ERKENNUNG (Nur Dez, Jan, Feb) --- #}
    {% if current_month in [12, 1, 2] %}
      {% set gestern_datum = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
      {% set gestern_data = data | selectattr('datum', 'equalto', gestern_datum) | list | first %}

      {% if gestern_data is defined %}
        {% set y_rest_gestern = gestern_data.ertrag_tag_rest | float(default=0) %}
        {% set h_rest_gestern = gestern_data.h_avg_rest | float(default=0) %}
        {% set perf_gestern = y_rest_gestern / ([105 - h_rest_gestern, 5] | max) %}
        {% if perf_gestern < 0.02 %}
          {% set schnee_faktor_heute = 0.1 %}
        {% endif %}
      {% endif %}
    {% endif %}

    {# --- 3. ASTRONOMISCHE BASISDATEN (ortsgenau via Breitengrad aus HA-Standort) --- #}
    {# latitude wird als Template-Variable vom Sensor übergeben (hass.config.latitude) #}
    {% set day_of_year = now().strftime('%j') | int(default=1) %}
    {% set lat_rad = latitude * pi / 180 %}
    {% set decl = -0.4093 * cos(2 * pi * (day_of_year + 10) / 365) %}
    {% set cos_ha = -tan(lat_rad) * tan(decl) %}
    {% set dl_today = 24 / pi * acos([[cos_ha, -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.65 + 0.35 * cos((day_of_year - 172) * 2 * pi / 365) %}

    {# --- 4. DATEN-POOL AUFBEREITEN --- #}
    {% set ns_pool = namespace(items=[], total_w=0) %}
    {% for item in data %}
      {% set yield_raw = item.ertrag_tag_rest | float(default=0) %}
      {% set clouds = item.h_avg_rest | float(default=0) %}
      {% set dt_item = as_datetime(item.datum) %}
      
      {% 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 cos_ha_i = -tan(lat_rad) * tan(decl_i) %}
        {% set dl_item = 24 / pi * acos([[cos_ha_i, -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.65 + 0.35 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set diff = (clouds - f_avg) | abs %}
        {% set w = 1 / ([diff, 0.5] | 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 + [{'h_avg': clouds, 'y_korr': yield_raw * s_korr, 'w': w}] %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {# --- 5. PROGNOSE-BERECHNUNG --- #}
    {% set pool = ns_pool.items %}
    {% set brighter = pool | selectattr('h_avg', 'lt', f_avg) | list %}
    {% set darker = pool | selectattr('h_avg', 'gt', 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_pool.total_w if ns_pool.total_w > 0 else 1) %}
    {% endif %}

    {# --- 6. FINALE SKALIERUNG --- #}
    {% set final_val = (res / (1000 if res > 200 else 1)) * schnee_faktor_heute %}
    {{ final_val | round(2) }}

{% else %}
  0.0
{% endif %}

SQL liefert uns nur rohe historische Zahlen. Das Template verarbeitet diese:
Saisonale Sonnen-Korrektur: Die Sonne steht im Juni viel höher als im Februar, daher wird der Einstrahlungswinkel und die Tageslänge korrigiert.
Schnee-Erkennung (Winter-Logik): Wenn es Dezember bis Februar ist und deine Anlage gestern trotz Licht fast 0W geliefert hat, geht das System davon aus, dass Schnee auf den Paneelen liegt. Die heutige Prognose wird dann sofort um 90% reduziert.
Intelligente Mittelwertbildung:
Gibt es Tage im Archiv, die heller UND dunkler waren? Dann wird ein gewichteter Mittelwert gebildet.
Ist der heutige Tag laut Wetterbericht schöner als alles, was wir in den letzten 60 Tagen gesehen haben? Dann wird eine Licht-Reduktion vom besten verfügbaren Tag abgezogen.

Markdown

[+]
{# =================================================================
   PV remaining yield today  Lovelace Markdown Card (Option B: Inline template)
   Source sensor:   sensor.pv_hist_remaining_today  (attribute: sql_raw_json)
   Forecast sensor: sensor.pv_hist_weather_forecast (attribute: forecast)

   RECOMMENDED: Use Option A instead of this inline template:
   {{ state_attr('sensor.pv_hist_remaining_today', 'lovelace_card') }}

   Option B: Use this content directly as a Lovelace Markdown card.
   ================================================================= #}
{% 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_heute_rest | float(default=50.0) %}

    {# 1. SAISONALE SCHNEE-ERKENNUNG (Dez / Jan / Feb) #}
    {% set current_month = now().month %}
    {% set schnee_faktor_heute = 1.0 %}
    {% if current_month in [12, 1, 2] %}
      {% set gestern_datum = (now() - timedelta(days=1)).strftime('%Y-%m-%d') %}
      {% set gestern_data = data | selectattr('datum', 'equalto', gestern_datum) | list | first %}
      {% if gestern_data is defined %}
        {% set y_rest_gestern = gestern_data.ertrag_tag_rest | float(default=0) %}
        {% set h_rest_gestern = gestern_data.h_avg_rest | float(default=0) %}
        {% set perf_gestern = y_rest_gestern / ([105 - h_rest_gestern, 5] | max) %}
        {% if perf_gestern < 0.02 %}{% set schnee_faktor_heute = 0.1 %}{% endif %}
      {% endif %}
    {% endif %}

    {# 2. ASTRONOMISCHE BASISDATEN (Breitengrad aus zone.home) #}
    {% 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 cos_ha = -tan(lat_rad) * tan(decl) %}
    {% set dl_today = 24 / pi * acos([[cos_ha, -1.0] | max, 1.0] | min) %}
    {% set sun_today = 0.65 + 0.35 * cos((doy - 172) * 2 * pi / 365) %}

    {# 3. POOL AUFBAUEN #}
    {% set ns_pool = namespace(items=[], total_w=0) %}
    {% for item in data %}
      {% set yield_raw = item.ertrag_tag_rest | float(default=0) %}
      {% set clouds = item.h_avg_rest | float(default=0) %}
      {% set clouds_gesamt = item.h_avg_gesamt | float(default=0) %}
      {% set item_dt = as_datetime(item.datum) %}
      {% 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 cos_ha_i = -tan(lat_rad) * tan(decl_i) %}
        {% set dl_item = 24 / pi * acos([[cos_ha_i, -1.0] | max, 1.0] | min) %}
        {% set sun_item = 0.65 + 0.35 * cos((item_day - 172) * 2 * pi / 365) %}
        {% set s_korr = (sun_today / sun_item) * (dl_today / dl_item) %}
        {% set diff = (clouds - f_avg) | abs %}
        {% set w = 1 / ([diff, 0.5] | 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 + [{'datum': item.datum, 'h_avg': clouds, 'h_avg_gesamt': clouds_gesamt, 'y_korr': yield_raw * s_korr, 's_fakt': s_korr, 'w': w, 'ertrag_tag_gesamt': item.ertrag_tag_gesamt, 'filtered': false}] %}
        {% else %}
          {% set ns_pool.items = ns_pool.items + [{'datum': item.datum, 'h_avg': clouds, 'h_avg_gesamt': clouds_gesamt, 'y_korr': yield_raw * s_korr, 's_fakt': s_korr, 'w': 0, 'ertrag_tag_gesamt': item.ertrag_tag_gesamt, 'filtered': true}] %}
        {% endif %}
      {% endif %}
    {% endfor %}

    {% set pool = ns_pool.items | selectattr('filtered', 'equalto', false) | list %}
    {% set brighter = pool | selectattr('h_avg', 'lt', f_avg) | list %}
    {% set darker = pool | selectattr('h_avg', 'gt', f_avg) | list %}
    {% set res = 0 %}
    {% set methode = "No data" %}

    {# 4. Decision logic #}
    {% if brighter | count > 0 and darker | count == 0 %}
      {% set methode = "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 methode = "Max assumption" %}
      {% set res = darker | map(attribute='y_korr') | max %}
    {% elif pool | count > 0 %}
      {% set methode = "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_pool.total_w if ns_pool.total_w > 0 else 1) %}
    {% endif %}

    {% set scale = 1000 if res > 200 else 1 %}
    {% set final_val = (res / scale) * schnee_faktor_heute %}

**Forecast:**
## {{ final_val | round(2) }} kWh
*Basis: **{{ f_avg }}%** clouds | **{{ methode }}***
{% if schnee_faktor_heute < 1.0 %}⚠️ **Snow suspected! ({{ (schnee_faktor_heute * 100) | round(0) }}%)**{% endif %}

| Date | Day clouds | Day yield | Rem. clouds | Rem. yield | Weight |
| :--- | :---: | :---: | :---: | :---: | :---: |
{%- for item in ns_pool.items | sort(attribute='w', reverse=True) %}
| {{ item.datum }} | {{ item.h_avg_gesamt }}% | {{ item.ertrag_tag_gesamt }} | **{{ item.h_avg }}%** | **{{ ((item.y_korr * schnee_faktor_heute) / scale) | round(2) }} <small><small>({{ item.s_fakt | round(2) }}x)</small></small>**{% if item.filtered %}❌{% endif %} | {{ (((item.w / ns_pool.total_w) * 100) if ns_pool.total_w > 0 else 0) | round(1) }}% |
{%- endfor %}

  {% else %}
**No data in SQL result.**
  {% endif %}
{% else %}
**Waiting for SQL data...**
{% endif %}

Morgen-Sensor

Vorhersage für den morgigen Tag: Helfer - Template Sensor:

[+]
{# --- DATENABRUF AUS SQL-SENSOR --- #}
{% set raw_json = state_attr('sensor.pv_remaining_statistics', 'json') %}

{% if raw_json and raw_json != '[]' and raw_json is not none %}
  {% set data = raw_json | from_json %}
  
  {# --- 1. DURCHSCHNITTLICHE BEWÖLKUNG MORGEN (bereits von SQL korrekt in UTC berechnet) --- #}
  {# Direktübernahme aus SQL-Daten vermeidet UTC/Lokalzeit-Fehler beim Forecast-Vergleich #}
  {% set f_avg_morgen = data[0].f_avg_morgen | float(default=50.0) %}

  {# ASTRONOMISCHE BASISDATEN MORGEN (ortsgenau via Breitengrad) #}
  {# latitude wird als Template-Variable vom Sensor übergeben (hass.config.latitude) #}
  {% set day_morgen = (now() + timedelta(days=1)).strftime('%j') | int %}
  {% set lat_rad = latitude * pi / 180 %}
  {% set decl_m = -0.4093 * cos(2 * pi * (day_morgen + 10) / 365) %}
  {% set dl_morgen = 24 / pi * acos([[(-tan(lat_rad) * tan(decl_m)), -1.0] | max, 1.0] | min) %}
  {% set sun_morgen = 0.65 + 0.35 * cos((day_morgen - 172) * 2 * pi / 365) %}

  {# --- 3. POOL MATCHING (HISTORISCHER VERGLEICH) --- #}
  {# Wir vergleichen den Forecast von morgen mit den Gesamterträgen der Vergangenheit #}
  {% set ns_pool = namespace(items=[], total_w=0) %}
  {% for item in data %}
    {% set yield_total = item.ertrag_tag_gesamt | float %}
    {% set clouds_hist = item.h_avg_gesamt | float %}
    
    {# Saisonale Korrektur: Skaliert den historischen Ertrag auf das Sonnen-Niveau von morgen #}
    {% set item_day = as_datetime(item.datum).strftime('%j') | int %}
    {% 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.65 + 0.35 * cos((item_day - 172) * 2 * pi / 365) %}
    {% set s_korr = (sun_morgen / sun_item) * (dl_morgen / dl_item) %}
    
    {# Gewichtung: Je näher die Bewölkung beieinander liegt, desto stärker das Gewicht #}
    {% set diff = (clouds_hist - f_avg_morgen) | abs %}
    {% set w = 1 / ([diff, 0.5] | max) %}
    
    {% set ns_pool.total_w = ns_pool.total_w + w %}
    {% set ns_pool.items = ns_pool.items + [{'y_korr': yield_total * s_korr, 'h_avg': clouds_hist, 'w': w}] %}
  {% endfor %}

  {# --- 4. ENTSCHEIDUNGSLOGIK --- #}
  {% set pool = ns_pool.items %}
  {% set brighter = pool | selectattr('h_avg', 'lt', f_avg_morgen) | list %}
  {% set darker = pool | selectattr('h_avg', 'gt', f_avg_morgen) | list %}
  {% set res = 0 %}

  {% if brighter | count > 0 and darker | count == 0 %}
    {# Fall A: Morgen wird dunkler als alle Tage im Pool -> Licht-Reduktion basierend auf dem schlechtesten Tag #}
    {% set worst_day = brighter | sort(attribute='y_korr') | first %}
    {% set res = worst_day.y_korr * ([120 - f_avg_morgen, 5.0] | max / [120 - worst_day.h_avg, 5.0] | max) %}
    
  {% elif darker | count > 0 and brighter | count == 0 %}
    {# Fall B: Morgen wird schöner als alle Tage im Pool -> Vorsichtige Max-Annahme #}
    {% set res = darker | map(attribute='y_korr') | max %}
    
  {% elif pool | count > 0 %}
    {# Fall C: Gemischter Pool -> Gewichteter Mittelwert aller Vergleichstage #}
    {% 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_pool.total_w %}
  {% endif %}

  {# Ergebnis-Ausgabe: Wh in kWh konvertieren falls Wert sehr hoch ist (Logik-Check) #}
  {% set final_scale = 1000 if res > 200 else 1 %}
  {{ (res / final_scale) | round(2) }}

{% else %}
  {# Fallback wenn SQL-Daten fehlen #}
  0.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: 24.03.2026 | Translation English |🔔 | Kommentare:0

Fragen / Kommentare


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