Ich möchte euch meine Art der Restprognosenberechnung teilen:
{% set hf = state_attr('sensor.prognose_heute', 'hourly_forecast') or {} %}
{% set anteil = (60 - now().minute) / 60 %}
{% set ns = namespace(s = (hf.get('%02d:00' % now().hour, 0) | float(0)) * anteil) %}
{% for h in range(now().hour + 1, 24) %}
{% set ns.s = ns.s + (hf.get('%02d:00' % h, 0) | float(0)) %}
{% endfor %}
{{ ns.s | round(2) }}
SFML passt die zukünftigen Stundenwerte mit an, falls es einen Cutoff gibt, somit steht hier immer die Wahrheit der Rest-Prognose drin. Die aktuelle Stunde wird anteilig gerechnet, damit der Wert flüssig weiterläuft und nicht erst zur Stunde springt.
Dieses Prinzip nutze ich auch, um mit Prognose und Lastprofil abzuschätzen, ob mein Akku ausreicht oder ob ich drüber bin. Das dann als Trigger, um verschiebbare Verbraucher gezielt zu starten. Ich hab Claude mal zusammentragen lassen, was dahinter steckt:
Akku-Überschusswarnung auf Basis der SFML-Stundenprognose
Zeigt an, wie viele kWh heute voraussichtlich ungenutzt bleiben, weil der
Akku vollläuft, bevor die PV-Erzeugung endet. Damit weiß ich mittags, ob ich
Waschmaschine/Trockner/Spülmaschine noch starten kann, ohne Netzstrom zu kaufen.
Die Warnung simuliert den restlichen Tag stundenweise:
freie Kapazität = (100 - SOC) / 100 * Akkukapazität
Dann je Reststunde Überschuss = PV-Prognose − erwartete Last. Positiver
Überschuss lädt den Akku, bis er voll ist; alles darüber ist Verlust und
wird aufsummiert. Negativer Überschuss entlädt (begrenzt auf die Kapazität).
Die laufende Stunde wird anteilig nach Restminuten gerechnet und nutzt die
Ist-Messwerte statt der Prognose.
Zwei Details, die den Unterschied machen
1. Direkt auf der Stundenkurve. Gelesen wird
state_attr('sensor.prognose_heute', 'hourly_forecast') — das Dict
{"00:00": kWh, ...}. Kein prognose_heute_rest, kein total_upcoming. Wer
daneben noch einen Restertrags-Sensor anzeigt, sollte den aus derselben Quelle
speisen, sonst zeigt die Karte einen Restertrag an, mit dem sie gar nicht
gerechnet hat.
2. Lastkorrektur k. Das 14-Tage-Lastprofil ist ein Mittel — es weiß nicht,
ob heute Homeoffice oder Büro ist, ob die Klimaanlage läuft, ob Ferien sind.
Ohne Korrektur ist die Warnung an Bürotagen systematisch zu pessimistisch.
k = Ist-Last der letzten 4 h ÷ Profil derselben 4 h, begrenzt auf
[0,35 … 1,3]. Bei mir sind allein die PCs 5,3 kWh/Tag von 12,7 kWh Profil —
an Bürotagen fällt k auf ~0,6, und erst dann feuert die Warnung überhaupt.
Voraussetzungen
Vier Ist-Werte, die jeder durch seine eigenen Entities ersetzen muss:
| Was |
bei mir |
muss liefern |
| Hausverbrauch (Leistung) |
sensor.ef_power_sys_load |
W, Momentanwert |
| PV-Erzeugung (Leistung) |
sensor.ef_pv_sum_all |
W, Momentanwert |
| Akku-SOC |
sensor.stream_battery_soc_total |
%, 0–100 |
| SFML-Prognose |
sensor.prognose_heute |
Attribut hourly_forecast |
Dazu kap im Template auf die eigene Akkukapazität setzen (bei mir 5,76 kWh).
Glättungs-Helfer (UI → Helfer → Filter-Sensor)
Rohe Leistungswerte springen zu stark, deshalb vier Filter:
| Name |
Quelle |
Filter |
Fenster |
Hausverbrauch geglaettet |
sensor.ef_power_sys_load |
Gleitender Mittelwert (Zeit) |
15 min |
PV Erzeugung geglaettet |
sensor.ef_pv_sum_all |
Gleitender Mittelwert (Zeit) |
15 min |
Hausverbrauch 1min Takt |
sensor.ef_power_sys_load |
Durchsatzbegrenzung (Zeit) |
1 min |
Hausverbrauch 4h Mittel |
sensor.hausverbrauch_1min_takt |
Gleitender Mittelwert (Zeit) |
4 h |
Alle mit Genauigkeit 0, Typ „letzter". Der 1-Minuten-Takt vor dem 4-h-Mittel ist
wichtig: ohne ihn frisst das 4-h-Fenster bei jedem Neustart zehntausende
Zustandsänderungen aus der Recorder-DB.
Lastprofil (Integration „SQL")
Kein Template-Helfer, sondern ein SQL-Sensor über die Langzeitstatistik. Er
liefert das 14-Tage-Mittel je Stunde als Attribute h00…h23 (W) plus h_now
und samples. Statistik-Mittelwerte statt Rohzustände — deshalb ist die Abfrage
billig und läuft auch auf schwacher Hardware.
Einstellungen → Geräte & Dienste → Integration hinzufügen → SQL:
- Spalte:
h_now
- Einheit
W, Geräteklasse power, Zustandsklasse measurement
- Abfrage (
sensor.ef_power_sys_load durch die eigene ersetzen):
SQL-Abfrage (aufklappen)
SELECT
ROUND(AVG(CASE WHEN h='00' THEN m END),0) AS h00,
ROUND(AVG(CASE WHEN h='01' THEN m END),0) AS h01,
ROUND(AVG(CASE WHEN h='02' THEN m END),0) AS h02,
ROUND(AVG(CASE WHEN h='03' THEN m END),0) AS h03,
ROUND(AVG(CASE WHEN h='04' THEN m END),0) AS h04,
ROUND(AVG(CASE WHEN h='05' THEN m END),0) AS h05,
ROUND(AVG(CASE WHEN h='06' THEN m END),0) AS h06,
ROUND(AVG(CASE WHEN h='07' THEN m END),0) AS h07,
ROUND(AVG(CASE WHEN h='08' THEN m END),0) AS h08,
ROUND(AVG(CASE WHEN h='09' THEN m END),0) AS h09,
ROUND(AVG(CASE WHEN h='10' THEN m END),0) AS h10,
ROUND(AVG(CASE WHEN h='11' THEN m END),0) AS h11,
ROUND(AVG(CASE WHEN h='12' THEN m END),0) AS h12,
ROUND(AVG(CASE WHEN h='13' THEN m END),0) AS h13,
ROUND(AVG(CASE WHEN h='14' THEN m END),0) AS h14,
ROUND(AVG(CASE WHEN h='15' THEN m END),0) AS h15,
ROUND(AVG(CASE WHEN h='16' THEN m END),0) AS h16,
ROUND(AVG(CASE WHEN h='17' THEN m END),0) AS h17,
ROUND(AVG(CASE WHEN h='18' THEN m END),0) AS h18,
ROUND(AVG(CASE WHEN h='19' THEN m END),0) AS h19,
ROUND(AVG(CASE WHEN h='20' THEN m END),0) AS h20,
ROUND(AVG(CASE WHEN h='21' THEN m END),0) AS h21,
ROUND(AVG(CASE WHEN h='22' THEN m END),0) AS h22,
ROUND(AVG(CASE WHEN h='23' THEN m END),0) AS h23,
ROUND(AVG(CASE WHEN h=strftime('%H','now','localtime') THEN m END),0) AS h_now,
COUNT(*) AS samples
FROM (
SELECT strftime('%H', s.start_ts, 'unixepoch', 'localtime') AS h, s.mean AS m
FROM statistics s
JOIN statistics_meta sm ON sm.id = s.metadata_id
WHERE sm.statistic_id = 'sensor.ef_power_sys_load'
AND s.mean IS NOT NULL
AND s.start_ts >= CAST(strftime('%s','now','-14 days') AS REAL)
);
Der Sensor
Template-Helfer (Einstellungen → Helfer → Vorlage → Sensor). Zustand:
{% set kap = 5.76 %}
{% set soc = states('sensor.stream_battery_soc_total') | float(0) %}
{% set anteil = (60 - now().minute) / 60 %}
{% set hh = '%02d' % now().hour %}
{% set hf = state_attr('sensor.prognose_heute', 'hourly_forecast') or {} %}
{# Lastkorrektur k: Ist-Last der letzten 4 h gegen Profil derselben 4 h #}
{% set prof_now = state_attr('sensor.lastprofil_14d', 'h_now') | float(0) %}
{% set load_now = states('sensor.hausverbrauch_geglaettet') | float(-1) %}
{% set pv_now = states('sensor.pv_erzeugung_geglaettet') | float(-1) %}
{% set load_4h = states('sensor.hausverbrauch_4h_mittel') | float(-1) %}
{% set ns2 = namespace(p = 0.0) %}
{% for i in range(4) %}
{% set ns2.p = ns2.p + (state_attr('sensor.lastprofil_14d', 'h%02d' % ((now().hour - i) % 24)) | float(0)) %}
{% endfor %}
{% set prof_4h = ns2.p / 4 %}
{% set k_raw = (load_4h / prof_4h) if (load_4h >= 0 and prof_4h > 0) else ((load_now / prof_now) if (load_now >= 0 and prof_now > 0) else 1.0) %}
{% set k = [[k_raw, 0.35] | max, 1.3] | min %}
{% set ns = namespace(frei = (100 - soc) / 100 * kap, verlust = 0.0) %}
{# laufende Stunde: Ist-Messwerte, anteilig nach Restminuten #}
{% set pv = [(hf.get(hh ~ ':00', 0) | float(0)) * anteil, ([pv_now, 0] | max) / 1000 * anteil] | max %}
{% set last = (([load_now, 0] | max) if load_now >= 0 else prof_now * k) / 1000 * anteil %}
{% set ueber = pv - last %}
{% if ueber > 0 %}
{% set laden = [ueber, ns.frei] | min %}
{% set ns.frei = ns.frei - laden %}
{% set ns.verlust = ns.verlust + (ueber - laden) %}
{% else %}
{% set ns.frei = [ns.frei - ueber, kap] | min %}
{% endif %}
{# volle Reststunden aus derselben Kurve #}
{% for h in range(now().hour + 1, 24) %}
{% set lastp = (state_attr('sensor.lastprofil_14d', 'h%02d' % h) | float(0)) * k / 1000 %}
{% set u = (hf.get('%02d:00' % h, 0) | float(0)) - lastp %}
{% if u > 0 %}
{% set laden = [u, ns.frei] | min %}
{% set ns.frei = ns.frei - laden %}
{% set ns.verlust = ns.verlust + (u - laden) %}
{% else %}
{% set ns.frei = [ns.frei - u, kap] | min %}
{% endif %}
{% endfor %}
{{ ns.verlust | round(2) }}
Verfügbarkeit:
{{ has_value('sensor.stream_battery_soc_total') and state_attr('sensor.prognose_heute', 'hourly_forecast') is not none and has_value('sensor.lastprofil_14d') }}
Einheit kWh, Zustandsklasse measurement — bewusst keine Geräteklasse
energy, es ist eine Vorhersage und kein Zählerstand.
Karte (Mushroom Template Card)
sensor.prognose_resttag_netto im Sekundärtext ist der Restertrags-Sensor aus meinem anderen Beitrag — wer den nicht hat, kürzt den else-Zweig einfach auf SOC und Last.
type: custom:mushroom-template-card
entity: sensor.pv_ueberschuss_prognose
icon: mdi:solar-power-variant
icon_color: >-
{% set v = states(entity) | float(0) %}{{ 'red' if v >= 0.6 else 'orange' if v
>= 0.15 else 'green' }}
primary: >-
{% set v = states(entity) | float(0) %}{{ (v | round(1)) ~ ' kWh ungenutzt' if
v >= 0.15 else 'Kein Ueberschuss' }}
secondary: >-
{% set v = states(entity) | float(0) %}{% set soc =
states('sensor.stream_battery_soc_total') | float(0) | round(0) %}{% set rest =
states('sensor.prognose_resttag_netto') | float(0) %}{% set l =
states('sensor.hausverbrauch_geglaettet') | float(0) | round(0) %}{% if v >=
0.15 %}Grossverbraucher jetzt starten - Akku {{ soc }} %, Last {{ l }} W{% else
%}Restertrag {{ rest | round(1) }} kWh - Akku {{ soc }} %, Last {{ l }} W{%
endif %}
Was man wissen sollte
k ist ein einziger Skalar für den ganzen Resttag. Er skaliert auch die
Abendlast mit, obwohl sich an einem Bürotag vor allem der Tag ändert. Sauberer
wäre, eine bekannte Grundlast (PCs o. ä.) direkt abzuziehen statt global zu
skalieren — das steht bei mir noch aus.
- Die Prognosekurve wird für vergangene Stunden nicht mit Ist-Werten
überschrieben. Für die Restberechnung ist das egal, weil nur die
Zukunftsstunden gelesen werden — bei einem Vergleich Soll gegen Ist muss man es
aber wissen.
- Getestet auf 2 kWp / 5,76 kWh. Die Schwellen 0,15 / 0,6 kWh und der
k-Klammerbereich [0,35 … 1,3] passen zu dieser Größenordnung, nicht
automatisch zu einer 10-kWp-Anlage.