76_SolarForecast - Informationen/Ideen zu Weiterentwicklung und Support

Begonnen von DS_Starter, 11 Februar 2024, 14:11:00

Vorheriges Thema - Nächstes Thema

300P

Zitat von: DS_Starter am 03 Januar 2026, 17:49:04Aus all diesen Dingen ermittelt FANN mit dem trainierten Modell die Prognose für den E-Verbrauch für Stunde X.
Nur als kleine Erläuterung wie das Ganze so funktioniert. Machine Learning Spezies mögen bitte Nachsicht üben wenn nicht alles korrekt ausgedrückt ist.  ;)

Was die Temperatur und andere "Umweltdaten" betrifft, wird es ein Attribut setupEnvironment geben, wo man als Nutzer Temperaturfühler, Anwesenheits-Devices und solche Dinge hinterlegen kann.

Danke - so hab ich das dann jetzt auch verstanden... :o  8)
Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

klaus.schauer

Zitat von: DS_Starter am 03 Januar 2026, 17:49:04
ZitatEvtl. wäre es ratsamer hier eine Abhängige Formel für die Temperatur und deren Verbrauch hinterlegen zu können?!?!?
Für FANN ist das nicht nötig. Die Abhängigkeiten zwischen Temperatur und Verbrauch ergeben sich aus den internen Verknüpfungen im Modul (den Semantiken), die ich der KI zum Lernen bereitstelle.
Konkret heißt das, FANN wird über die Rohdaten verschiedene historische Kennwerte bereitgestellt, z.B.:

- Monat  (darüber die Jahreszeit)
- Wochentag
- Stunde des Tages
- Außentemperatur  (zur Zeit als Aufzeichnung vom Wetterdienst was hier ungünstig ist)
- E-Verbrauch pro Stunde
- Arbeitstag oder Wochenende
- Anwesenheit bzw. Urlaub/Feiertag  (zukünftig)
- PV-Ertrag
- Sonnstand (Azimut, Altitude)
- usw...

Es werden daraus noch etliche Zusatzsignale bereitgestellt, wie z.B. Deltas der letzen Stunden und vieles andere. Während des Trainingsprozesses stellt FANN Verknüpfungen/Gewichtungen zwischen all diesen Werten her und erkennt im Besten Fall die richtigen Muster, z.B. wenn 3 Grad kalt und Dezember und es ist Abends 20:00 an einem Wochende ist der E-Verbrauch typischerweise so und so hoch. FANN weiß nicht dass es eine WP gibt, nur die Rahmendaten zum Verbrauch.
Für die Abfrage muß ich dementsprechend Prognosedaten bereitstellen, also z.B.:

- in den nächsten Stunden wird es soviel Temp sein (Wetterprognose)
- wird es Arbeitstag und Uhrzeit sein (trivial)
- wird es soviel PV geben (PV Prognose)
- wird Anwesenheit im Haus sein (Feiertags/Urlaubsprognose)
- wird die bestimmte Jahreszeit sein (trivial)
- wird der Sonnenstand x/y sein (Prognose aus PAH's Astro)
- usw.
Ich bin kein KI-Experte, aber ich bezweifle, dass sich die Prognosen nicht verbessern lassen, wenn man zusätzlich deterministische Berechnungen für den Verbrauch einer Wärmepumpe als Prognosedaten einbezieht. Die Qualität der Mustererkennung hängt nun mal von möglichst charakteristischen Eingangsdaten ab.

DS_Starter

#4727
ZitatIch bin kein KI-Experte, aber ich bezweifle, dass sich die Prognosen nicht verbessern lassen, wenn man zusätzlich deterministische Berechnungen für den Verbrauch einer Wärmepumpe als Prognosedaten einbezieht.
Absolut richtig und sogar sehr wichtig. Und das wird auch gemacht. Dafür gibt es im Modul eine sogenannte FEATURE-REGISTRY in der zusätzliche Semantiken hergestellt werden. Gibt es beispielsweise einen WP spezifischen Kennwert bzw. Faktor, der mit der Temperatur korreliert und einen Bezug zum Verbrauch herstellt, könnte ich diesen Faktor für einen semantischen Zusammenhang verwenden.
Ein solcher Kennwert muß dann aber im z.B. Consumer-Stammsatz der WP hinterlegt werden UND bei jeder! WP verfügbar sein, denn solche Featuresets müssen immer vollständig sowohl beim Training UND bei der Abfrage vorliegen.
Wenn es einen solchen Wert/Faktor bei allen! WP gibt, kann ich ihn als verpflichtend anzugeben definieren und verwenden, aber keine in einem Attr hinterlegte Formel. So geht das leider nicht.
Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

DS_Starter

@300P,

um nochmal auf deine Formel zurück zu kommen ...

Gesetzt den Fall in allen WP Produkten gäbe es einen für den User verfügbaren Kennwert/Faktor, der wenigstens näherungsweise einen Zusammenhang zwischen Temperatur und Leistung herstellt, könnte ich ihn für ein Feature verwenden. Komplexe Formelgebilde kann ich in einem Consumer Attribut-Schlüssel nicht verwenden, zumal auch jeder User in der Lage sein muß zu erkennen was er dort eintragen müßte.

Aber vllt. hast du eine andere Idee? Also ein einfacher! Kennwert der Temperatur und Leistung der WP in Beziehung setzt.   
Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

300P

Nunja - den Wert habe ich über alle stündlichen max. Werte seit dem 30.09.2025 - 03.11.2026 und den ca. 30 Sekundenwerten vom 4.11.2025-03.01.2026 gebildet und aufgrund dieser von der  Buderus-WP per ESP-EMS geholten Werte innerhalb von einer Excel Grafik mit der Formel anzeigen lassen.

Ich rede von da von ,,nur" jeweils ca. 130.000 Temperatur- und Verbrauchsdatensätzen in meiner SQL.


Das wird nicht jeder WP-Besitzer so können bzw. zur Verfügung haben..

Das war noch relativ simple.


Die WP-Hersteller geben derartige angedachten Tabellen oder Formeln zur Berechnung sicherlich nicht raus. Sie geben nur das Nötigste an Informationen raus, was allgemein zugänglich ist, immer nur für ein paar feste Temperaturwerte bei festen Parametern.

Eigentlich bin ich nur darauf gekommen dies so zu erstellen, weil ich vorher eine x/y Grafik erstellt hatte um zu sehen mit welcher Arbeitszahl bei welcher Temperatur an der WP ich rechnen kann......

Mehr zu meiner allgemeinen Info aufgrund der bisherigen Datenermittlungen, nicht als Grundlage zu irgendwelchen Berechnungen.

Wenn ich eine Idee haben würde wie die Formel für alle WP lauten würde und nicht nur eine die für allein "meine" WP an meinem Aufstellungsortes gelten würde wäre ich sicherlich der König 🤴 in der Heizungsbranche.
Damit könnten man ja relativ treffsicher den Verbrauch vorhersagen.

Ich zweifle selber an das dies möglich sein könnte!?!

Es gibt zu viele lokale Einflüsse durch die Nutzung / die Gebäude / die Umwelt / den Aufbau etc. um es einfach in eine Formel zu bringen die alle WP erschlägt.
Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

DS_Starter

Danke für die Info und deine Gedanken. Im Prinzip habe ich über die Aufzeichnungen die stündlichen Temp und später auch die WP-Verbräuche verfügbar. Darüber könnte ich evtl. für jede Stunde eine Ableitung aus diesen Werten rechnen, als neuen Wert speichern und als weiteren Feature Input nutzen.
Nur mal so als Idee, kann noch nicht sagen ob das sinnvoll machbar und nutzbar ist. 
Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

300P

Ich habe jetzt ein Ergebnis was erstmalig so "lala" in der "Forecats-Höhe" aussieht und lasse es so laufen:


aiTrainStart=3
aiStorageDuration=3600
aiTreesPV=30
aiConActivate=1
aiConAlpha=0.3
aiConTrainStart=1:2
aiConShuffleMode=1
aiConActFunc=GAUSSIAN

Informationen zum neuronalen Netz der Verbrauchsvorhersage

letztes KI-Training: 03.01.2026 20:37:46 / Laufzeit in Sekunden: 5078
KI Abfragestatus: ok
letzte KI-Ergebnis Generierungsdauer: 28.78 ms

=== Modellparameter ===

Normierungsgrenzen: PV=17532 Wh, Hausverbrauch: Min=0 Wh / Max=5920 Wh
Trainingsdaten: 6790 Datensätze (Training=5432, Validierung=1358)
Architektur: Inputs=34, Hidden Layers=80-40-20, Outputs=1
Hyperparameter: Learning Rate=0.005, Momentum=0.5, BitFail-Limit=0.35
Aktivierungen: Hidden=GAUSSIAN, Steilheit=0.9, Output=LINEAR
Zufallsgenerator: Mode=1, Periode=10

=== Trainingsmetriken ===

bestes Modell bei Epoche: 1640 (von max. 15000)
Training MSE: 0.005855
Validation MSE: 0.009955
Validation MSE Average: 0.012920
Validation MSE Standard Deviation: 0.000802
Validation Bit_Fail: 1
Model Bias: 763 Wh
Model Slope: 0.6
Trainingsbewertung: Retrain

=== Fehlermaße der Prognosen ===

MAE: 457.04 Wh
MedAE: 366.58 Wh
RMSE: 557.68 Wh
RMSE relative: 28 %
RMSE Rating: weak
MAPE: 23.31 %
MdAPE: 19.79 %
R²: 0.53

=== Drift-Kennzahlen ===

Drift Score: -
Drift RMSE relative: -
Drift Bias: -
Drift Slope: -
Drift Bewertung: -


Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

300P

Zitat von: DS_Starter am 03 Januar 2026, 21:11:36Danke für die Info und deine Gedanken. Im Prinzip habe ich über die Aufzeichnungen die stündlichen Temp und später auch die WP-Verbräuche verfügbar. Darüber könnte ich evtl. für jede Stunde eine Ableitung aus diesen Werten rechnen, als neuen Wert speichern und als weiteren Feature Input nutzen.
Nur mal so als Idee, kann noch nicht sagen ob das sinnvoll machbar und nutzbar ist. 


Spezifiziere doch schon mal einen Consumer-type "Wärmepumpe"
consumer08
SMA_Elgris_EM2
type=noSchedule
power=0
icon=sani_heating_heatpump@orange
pcurr=Bezug_Wirkleistung:W
etotal=Bezug_Wirkleistung_Zaehler:kWh
noshow=0
Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

DS_Starter

Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

300P

Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

DS_Starter

Wenn man erstmal ein gut trainiertes Netz hat, ist ein Retraining nicht so häufig nötig. Ich habe selbst noch keinen Plan wie häufig es sinnvoll ist und habe die Periode erstmal per default auf 7 Tage gesetzt. Möglicherweise noch zu häufig. Mal sehen
Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

DS_Starter

Proxmox+Debian+MariaDB, PV: SMA, Victron MPII+Pylontech+CerboGX
Maintainer: SSCam, SSChatBot, SSCal, SSFile, DbLog/DbRep, Log2Syslog, SolarForecast,Watches, Dashboard, PylonLowVoltage
Kaffeekasse: https://www.paypal.me/HMaaz
Contrib: https://svn.fhem.de/trac/browser/trunk/fhem/contrib/DS_Starter

Gisbert

Hallo Heiko,
hallo 300p,

welche Attribute und Settings muss bzw. sollte man für AI setzen. Ich hab das Wiki gelesen, weiß aber nicht genau, was ich machen soll.

Viele Grüße Gisbert
Proxmox | UniFi | Homematic, VCCU, HMUART | ESP8266 | ATtiny85 | Wasser-, Stromzähler | tuya local | Wlan-Kamera | SIGNALduino, Rauchmelder FA21/22RF | RHASSPY | DEYE | JK-BMS | ESPHome | Panasonic Heishamon

300P

Kurzanleitung:

Meist schreibe ich mir am Anfang von neuen Sachen alle Parameter mit default-Wert - egal ob ich Standart nutze oder nicht -  zur Übersicht mit in die jeweiligen Attribute hinein um dann den einen deren evtl. mal dann auszuprobieren.
=>> Aber das ist Ansichtssache

Zu Anfang aber mind. eintragen (m.W.n.)
!!hinzufügen !!
attr >Forecast< ctrlDebug aiProcess
!!hinzufügen !!
attr <Forecast> aiConActivate=1

Dann zum ersten Start des Trainings mittels
set Forecast aiDecTree runConTrain.....und das Logbuch beobachten....
Kann bis zu  2-4 Stunden dann dauern ehe dieser Lauf beendet ist.

Dann optimieren durch "Feintuning" und diverse Einstellungen............ ;D

Der Rest nach dem WIKI bzw. Infos aus dem Thread ;)
Gruß
300P

FHEM 6.4|RPi|SMAEM|SMAInverter|SolarForecast|DbLog|DbRep|MariaDB|Buderus-MQTT_EMS|
Fritzbox|fhempy|JsonMod|HTTPMOD|Modbus ser+TCP|ESP32-Digitizer-AI_on_the_Edge|ESP32CAM usw.

klaus.schauer

Zitat von: DS_Starter am 04 Januar 2026, 14:56:14Ein interessanter Artikel zum Thema WP und Energievorhersage und ziemlich vollständig das beschreibt was auch im SF umgesetzt ist:
https://medium.com/@omegaxp3/predicting-future-energy-consumption-using-neural-network-based-on-historical-data-and-temperature-819028398301
Interessant an den Ergebnissen ist, dass die Vorhersagen bei nicht monotonem Verhalten stark fehlerbehaftet waren. Und dabei hat man sich ausschließlich auf eine Korrelation zwischen Energieverbrauch, Außentemperatur und die zeitliche Abhängigkeit konzentriert.

In dein geplantes KI-Model sollen viele weitere Randbedingungen einfließen. Auch soll wohl der Energieverbrauch als Ganzes betrachtet werden. Das macht die Sache sicherlich nicht einfacher und bestärkt mich in meiner Skepsis. Konkret bringt uns deshalb das Training mit mehr oder weniger beziehungslosen historischen Energiedaten oder auch der Wunsch nach einer noch unbekannten Standardkenngröße von Wärmepumpen für den aktuellen Energieverbrauch nicht weiter.

Unabhängig von der Art des Wärmeerzeugers benötigt ein Gebäude eine bestimmte Heizleistung, um eine gewünschte Innentemperatur zu gewährleisten. Deshalb wäre m. E. sinnvoll, dies als Führungsgröße bereitzustellen, die deterministisch berechnet wird.

Für die Energiebedarfsprognose einer Wärmepumpe reichen wenige deterministische Daten. Vom Gebäude sollten der Verlustkoeffizient H [W/K] aus Heizlastberechnung (DIN EN 12831), die Wärmekapazität des Gebäudes C [Wh/K] und COP-Wert der Wärmepumpe bekannt sein oder abgeschätzt werden. Als Wetterdaten werden die Prognosen für Außentemperatur und die Windgeschwindigkeit benötigt. Die Sollinnentemperatur kann über die hinterlegten Zeitfenster der Heizung abgebildet werden.  Aus diesen Daten lässt sich die benötigte Eingangsleistung und der Energiebedarf z. B. in Stundenintervallen abschätzen. Über die Wärmekapazität C und die Windgeschwindigkeit kann zusätzlicher dynamischer Energiebedarf beim Aufheizen und durch Infiltrationswärmeverluste berücksichtigt werden.

Wie dies konkret berechnet wird, kann man der Routine myUtils_heatingEnergyDemand entnehmen, siehe unten. Die spezifischen Gebäudedaten und die COP-Werte der Wärmepumpe sind natürlich passend zu ermitteln. Die Berechnung kann natürlich das Nutzerverhalten oder auch Systemspezifika des Wärmeerzeugers wie Taktung, die Größe von Pufferspeichern oder Unterbrechungen durch Warmwasserbereitung nicht abbilden. Da wäre dann die KI gefragt.

Wenn man eine solche Berechnung nicht in SolarForecast einbauen wollte, könnte man mit einer Benutzerfunktion ähnlich wie bei ctrlUserExitFn Leistung oder Energie / h und die Sollinnentemperatur abrufen:
sub myUtils_heatingEnergyDemand {
  my ($hash, $name, $consumerNum, $timeOffset) = @_;

...

return ($energy, $setpointTemp);
}
Aktuell berechne ich die Energie / h mit nachfolgender Routine und schreibe den aktuellen Wert und die Prognosen in zusätzliche SolarForecast Readings. Die Funktion myUtils_heatingEnergyDemand wird in der ctrlUserExitFn zusammen mit anderen Hilfsfunktionen  aufgerufen und stündlich ausgeführt:
sub myUtils_ctrlUserExitFn {
  my ($hash, $name, $wrName, $hpName) = @_;

  myUtils_buildGridTariffArray($hash, $name);
  myUtils_buildSpotTariffHash($hash, $name);
  myUtils_consumerEnergyLastHour($hash, $name,'01'); 

  my ($sec, $min, $hour) = localtime;
  # Berechne den aktuellen 15-Minuten-Intervall-Start (z.B. 08:15, 08:30, ...)
  my $intervalMin = int($min / 15) * 15;
  my $intervalTag = sprintf("%02d:%02d", $hour, $intervalMin);
 
  # Ausfuehrung jeweils einmal zu Beginn eines 15 min Intervalls
  if (!exists($hash->{helper}{ctrlUserExitFn}{lastInterval}) || $hash->{helper}{ctrlUserExitFn}{lastInterval} ne $intervalTag) {
    $hash->{helper}{ctrlUserExitFn}{lastInterval} = $intervalTag;
    readingsBeginUpdate($hash);
    readingsBulkUpdateIfChanged($hash, 'gridTariff', myUtils_getCurrentGridTariff($hash, $name, sprintf("%02d:%02d", $hour, $min)), 1);
    readingsBulkUpdateIfChanged($hash, 'gridEnergyFixedPrice', myUtils_getCurrentTotalEnergyCosts($hash, $name, sprintf("%02d:%02d", $hour, $min)), 1);
    readingsBulkUpdateIfChanged($hash, 'gridEnergySpotPrice', myUtils_GetCurrentSpotEnergyCosts($hash, $name, sprintf("%02d:%02d", $hour, $min)), 1);
    readingsEndUpdate($hash, 1);
    myUtils_pvBatChargeRequest($hash, $name, $wrName);
    myUtils_heatingEnergyDemand($hash, $name, '01');
    myUtils_HPHcCharge($hash, $name, $hpName, $intervalTag);
    myUtils_HPHwcCharge($hash, $name, $hpName, $intervalTag);
  }

  return;
}
Die Funktion  myUtils_heatingEnergyDemand holt sich u. a. die Sollinnentemperatur unmittelbar aus der Wärmepumpe. Die Windgeschwindigkeit aus dem Modul DWD_OpenData.

Falls man den Weg über die vorgeschlagene Benutzerfunktion gehen würde, wäre die Funktion problemlos anpassbar. Festzulegen wäre dann noch,
   - ob der Berechnungszeitpunkt mit Datum und Uhrzeit oder als Zeitoffset übergeben wird und
   - ob die Eingangsleistung oder die Energie / t zurückgegeben werden soll.

Gut wäre auch, wenn SolarForecast neben den Temperaturdaten auch die Windgeschwindigkeit bereitstellen würde.     
# Schaetzung des Energiebedarfs faer Heizung abhaengig vom Verlustkoeffizient aus Heizlastberechnung (DIN EN 12831) und der Aussentemperatur
sub myUtils_heatingEnergyDemand {
  my ($hash, $name, $consumerNum) = @_;

  # Initialisiere Helper-Struktur bei Bedarf
  $hash->{helper}{ctrlUserExitFn} //= {};

  # Zeitabfrage
  my ($sec, $min, $hour, $mday, $mon, $year) = localtime;
  my $nowEpoch = time;
  my $lastRunHour = $hash->{helper}{ctrlUserExitFn}{lastRunHour} // -1;
  return undef if ($lastRunHour == $hour);

  # Konstanten
  my $copMax = 5;
  my $copMin = 2.5;
  my $correctionFactor = 0.8;
  my $energyDiffMax = 0.3; # max. dynamischer Energieaufschlag
  my $energyHwcHour = 0; # Energiebedarf / h fuer Warmwasser
  my $heatCapacity = 16; # Waermekapazität des Gebaeudes C [Wh/K]
  my $heatingOutdoorDesignTemp = -8.5; # Auslegungstemperatur
  my $heatingLimitTemp = ReadingsVal('Heizung_ctlv3', 'ctlv3_Hc1SummerTempLimit', 16);
  my $helperKey = 'consumer' . $consumerNum;
  my $lossCoeff = 300; # Verlustkoeffizient H [W/K] aus Heizlastberechnung (DIN EN 12831)
  my $setpointSlotTemp = 20;
  my $setpointBackTemp = ReadingsVal('Heizung_ctlv3', 'ctlv3_z1SetBackTemp', 10);
  my $quickVetoTemp = ReadingsVal('Heizung_ctlv3', 'ctlv3_z1QuickVetoTemp', 20);
  my $periodSlotTemp = "04:30-21:00";
  my $z1Status = ReadingsVal('Heizung_ctlv3', 'ctlv3_z1Status', 'auto');

  # Endzeitpunkte parsen
  my ($holidayEndDay, $holidayEndMonth, $holidayEndYear) = split(/\./, ReadingsVal('Heizung_ctlv3', 'ctlv3_z1HolidayEndDate', '01.01.2000'));
  my ($holidayEndHour, $holidayEndMin, $holidayEndSec)   = split(/:/, ReadingsVal('Heizung_ctlv3', 'ctlv3_z1HolidayEndTime', '00:00:00'));
  my ($quickVetoEndDay, $quickVetoEndMonth, $quickVetoEndYear) = split(/\./, ReadingsVal('Heizung_ctlv3', 'ctlv3_z1QuickVetoEndDate', '01.01.2000'));
  my ($quickVetoEndHour, $quickVetoEndMin, $quickVetoEndSec)   = split(/:/, ReadingsVal('Heizung_ctlv3', 'ctlv3_z1QuickVetoEndTime', '00:00:00'));

  my $holidayEpoch    = timelocal($holidayEndSec, $holidayEndMin, $holidayEndHour, $holidayEndDay, $holidayEndMonth - 1, $holidayEndYear);
  my $quickVetoEpoch  = timelocal($quickVetoEndSec, $quickVetoEndMin, $quickVetoEndHour, $quickVetoEndDay, $quickVetoEndMonth - 1, $quickVetoEndYear);

  # Slot-Zeitraum parsen
  my ($startSlot, $endSlot) = split /-/, $periodSlotTemp;
  my ($startHour, $startMin) = split /:/, $startSlot;
  my ($endHour, $endMin) = split /:/, $endSlot;

  # Hilfsfunktion: Slot-Temperatur
  sub getSetpointTempForHour {
    my ($h, $startHour, $startMin, $endHour, $endMin, $slotTemp, $backTemp) = @_;
    return $slotTemp if (
      ($h > $startHour && $h < $endHour) or
      ($h == $startHour && $startMin == 0) or
      ($h == $endHour && $endMin > 0)
    );
    return $backTemp;
  }

  # Hilfsfunktion: Sonderstatus prüfen
  sub overrideSetpointIfActive {
    my ($status, $epochNow, $holidayEpoch, $quickVetoEpoch, $backTemp, $vetoTemp) = @_;
    return $backTemp if ($status eq 'holiday_away' && $epochNow < $holidayEpoch);
    return $vetoTemp if ($status eq 'quick_veto'   && $epochNow < $quickVetoEpoch);
    return undef;
  }

  # Energiebedarf / h (statisch / dynamisch)
  sub heatingCalcDynamic {
    my ($lossCoeff, $copMax, $copMin, $correctionFactor, $heatCapacity, $energyDiffMax, $energyHwcHour, $setpointTemp, $setpointTempLast, $tempOutside, $heatingOutdoorDesignTemp, $heatingLimitTemp, $wind) = @_;
    return (0, 0, 0) if ($tempOutside >= $setpointTemp || $tempOutside >= $heatingLimitTemp);

    # COP Wert in Abhaengigkeit der Aussentemperatut (lineare Interpolation)
    my $cop = $copMin + ($copMax - $copMin) * (($tempOutside - $heatingOutdoorDesignTemp) / ($heatingLimitTemp - $heatingOutdoorDesignTemp));
    # Falls unterhalb der Auslegungstemperatur, COP bleibt minimal
    $cop = $copMin if ($tempOutside <= $heatingOutdoorDesignTemp);
    # Falls oberhalb der maximalen Temperatur, COP bleibt maximal
    $cop = $copMax if ($tempOutside >= $heatingLimitTemp);

    # Basislast: Verlustkoeffizient * Temperaturdifferenz
    my $energyBase = $lossCoeff / $cop * $correctionFactor * ($setpointTemp - $tempOutside);

    # Windkorrekturfaktor (Annahme: ab 20 km/h Wind steigt Infiltration linear um bis zu +20 %)
    my $windFactor = 1.0;
    if (defined $wind && $wind > 20) {
        # Skaliere zwischen 20 km/h und 60 km/h
        my $windGradient = ($wind - 20) / 40; # 0 bis 1
        $windGradient = 1 if $windGradient > 1; # Deckelung
        $windFactor += 0.2 * $windGradient; # max. +20 %
    }
    $energyBase *= $windFactor;

    # Änderung der Solltemperatur pro Stunde
    my $deltaTsoll = $setpointTemp - $setpointTempLast;

    # Dynamische Zusatzlast: Gebaeudekapazität * Temperaturänderung / 1h
    # (1h = 3600s, hier vereinfachend pro Stunde gerechnet)
    my $energyDyn = $heatCapacity * $deltaTsoll;

    # Begrenzung der Zusatzlast auf Prozentsatz der Basislast
    my $energyDynMax = $energyBase * $energyDiffMax;
    $energyDyn = $energyDynMax if $energyDyn > $energyDynMax;
    $energyDyn = 0 if $energyDyn < 0;   # keine negative Zusatzlast

    # Gesamtenergie inkl Energie fuer Warmwasser / h
    $energyBase += $energyHwcHour;
    my $energyTotal = $energyBase + $energyDyn;

    return ($energyBase, $energyDyn, $energyTotal, $cop);
  }

  sub toDayHour {
    my ($value) = @_;
    # Ganze Tage = Division durch 24
    my $day  = int($value / 24);
    # Reststunden = Modulo 24
    my $hour = $value % 24;
   return ($day, $hour);
  }

  readingsBeginUpdate($hash);

  # Aktuelle Stunde
  my $setpointTemp = overrideSetpointIfActive($z1Status, $nowEpoch, $holidayEpoch, $quickVetoEpoch, $setpointBackTemp, $quickVetoTemp);
  $setpointTemp //= getSetpointTempForHour($hour, $startHour, $startMin, $endHour, $endMin, $setpointSlotTemp, $setpointBackTemp);
  my $setpointTempLast = $hash->{helper}{$helperKey}{setpointTemp}{current} // $setpointTemp;
  my $tempHour = FHEM::SolarForecast::CurrentVal($name, 'temp', $heatingLimitTemp);
  my ($energyBase, $energyDyn, $energyTotal, $cop) = heatingCalcDynamic ($lossCoeff, $copMax, $copMin, $correctionFactor, $heatCapacity, $energyDiffMax,
                                                                   $energyHwcHour,
                                                                   $setpointTemp, $setpointTempLast,
                                                                   $tempHour, $heatingOutdoorDesignTemp, $heatingLimitTemp,
                                                                   ReadingsVal("Wetter_DWD", "fc0_" . $hour . "_FF", 0));
  $hash->{helper}{$helperKey}{setpointTemp}{current} = $setpointTemp;
  readingsBulkUpdateIfChanged($hash, 'consumer' . $consumerNum . '_consumptionCalc_Current', int($energyBase) . " Wh", 1);
  readingsBulkUpdateIfChanged($hash, 'consumer' . $consumerNum . '_consumptionCalcDyn_Current', int($energyTotal) . " Wh", 1);
  readingsBulkUpdateIfChanged($hash, 'consumer' . $consumerNum . '_copCalc_Current', sprintf('%0.1f', $cop), 1);

  # Folge-Stunden
  for my $indexHour (0 .. 23) {
    my $futureEpoch = $nowEpoch + ($indexHour + 1) * 3600;
    my ($futureDay, $futureHour) = toDayHour ($hour + $indexHour + 1);
    $setpointTemp = overrideSetpointIfActive($z1Status, $futureEpoch, $holidayEpoch, $quickVetoEpoch, $setpointBackTemp, $quickVetoTemp);
    $setpointTemp //= getSetpointTempForHour($futureHour, $startHour, $startMin, $endHour, $endMin, $setpointSlotTemp, $setpointBackTemp);
    $hash->{helper}{$helperKey}{setpointTemp}{$indexHour} = $setpointTemp;
    $setpointTempLast = $indexHour == 0 ? $hash->{helper}{$helperKey}{setpointTemp}{current} // $setpointTemp : $hash->{helper}{$helperKey}{setpointTemp}{$indexHour - 1} // $setpointTemp;
    $tempHour = FHEM::SolarForecast::NexthoursVal($name, 'NextHour' . sprintf('%02d', $indexHour), 'temp', $heatingLimitTemp);
    ($energyBase, $energyDyn, $energyTotal, $cop) = heatingCalcDynamic ($lossCoeff, $copMax, $copMin, $correctionFactor, $heatCapacity, $energyDiffMax,
                                                                  $energyHwcHour,
                                                                  $setpointTemp, $setpointTempLast,
                                                                  $tempHour, $heatingOutdoorDesignTemp, $heatingLimitTemp,
                                                                  ReadingsVal("Wetter_DWD", "fc" . $futureDay . "_" . $futureHour . "_FF", 0));
    readingsBulkUpdateIfChanged($hash, 'consumer' . $consumerNum . '_consumptionCalc_NextHour' . sprintf('%02d', $indexHour), int($energyBase) . " Wh", 1);
    readingsBulkUpdateIfChanged($hash, 'consumer' . $consumerNum . '_consumptionCalcDyn_NextHour' . sprintf('%02d', $indexHour), int($energyTotal) . " Wh", 1);
  }

  readingsEndUpdate($hash, 1);

  # Merke letzte Ausführungsstunde
  $hash->{helper}{ctrlUserExitFn}{lastRunHour} = $hour;

  return undef;
}