Files
Symcon_Belevo_Energiemanage…/Bat_EV_SDL_V4/module.php
T
2026-05-12 13:53:52 +02:00

1255 lines
44 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Bat_EV_SDL_V4
*
* Zweck des Moduls:
* Dieses IPS-Modul verteilt Lade- und Entlade-Sollwerte auf mehrere Batteriesysteme.
* Dabei werden zwei virtuelle Energiekonten geführt:
*
* - SDL: reservierter Bereich für SDL-Leistung
* - EV: restlicher nutzbarer Bereich für EV-Leistung
*
* Zusätzlich werden reale Batterie-SoC-Werte gelesen, um Lade- und Entladegrenzen
* dynamisch zu berücksichtigen.
*
* Vorzeichenlogik in diesem Modul:
* - Positive Leistung = Laden
* - Negative Leistung = Entladen
*
* Achtung:
* Das Modul führt aktuell nur globale virtuelle SDL-/EV-Konten.
* Die virtuelle Energie wird also nicht separat pro Batterie geführt.
*/
class Bat_EV_SDL_V4 extends IPSModule
{
public function Create()
{
parent::Create();
// Properties
$this->RegisterPropertyString("Batteries", "[]");
$this->RegisterPropertyInteger("SDL_Leistung_Laden", 0); // W
$this->RegisterPropertyInteger("SDL_Leistung_Entladen", 0); // W
$this->RegisterPropertyFloat("ReserveHours", 0.5); // h
$this->RegisterPropertyFloat("SDL_Start_Pos_Config", 50.0); // %
$this->RegisterPropertyFloat("EV_Start_Pos_Config", 50.0); // %
$this->RegisterPropertyInteger("UpdateInterval", 2); // Sekunden
$this->RegisterPropertyBoolean("FilterAktiv", true);
$this->RegisterPropertyFloat("FilterTolerancePct", 15.0); // %
$this->RegisterPropertyFloat("FilterRampWPerSec", 2000.0); // W/s
$this->RegisterPropertyInteger("FilterHits", 1);
$this->RegisterPropertyFloat("EV_Recalc_IntervalHours", 6.0);
$this->RegisterPropertyFloat("EV_Recalc_TolerancePct", 2.0);
// Status
$this->RegisterVariableBoolean("State", "Aktiv", "~Switch", 1);
$this->EnableAction("State");
// Sollwerte
$this->RegisterVariableFloat("Nennleistung_Soll_EV", "Nennleistung Soll EV", "", 2);
$this->RegisterVariableFloat("Nennleistung_Soll_SDL", "Nennleistung Soll SDL", "", 3);
// Aktuelle Leistung / Rückrechnung
$this->RegisterVariableFloat("Aktuelle_Leistung_SDL", "Aktuelle Leistung SDL", "", 4);
$this->RegisterVariableFloat("Aktuelle_Leistung_EV", "Aktuelle Leistung EV", "", 5);
// Virtuelle SoC Werte
$this->RegisterVariableFloat("SDL_Pos", "SDL Energie verfügbar virtuell (%)", "", 10);
$this->RegisterVariableFloat("SoC_EV", "EV Energie verfügbar virtuell (%)", "", 11);
// Maximalleistungen
$this->RegisterVariableFloat("P_SDL_laden", "P SDL laden max (W)", "", 21);
$this->RegisterVariableFloat("P_SDL_entladen", "P SDL entladen max (W)", "", 22);
$this->RegisterVariableInteger("P_EV_laden", "P EV laden max (W)", "", 31);
$this->RegisterVariableInteger("P_EV_entladen", "P EV entladen max (W)", "", 32);
// Startwerte / Reset
$this->RegisterVariableFloat("SDL_Start_Pos", "SDL Start SoC (%)", "", 35);
$this->RegisterVariableFloat("EV_Start_Pos", "EV Start SoC (%)", "", 36);
$this->RegisterVariableBoolean("Virtual_Reset", "Virtuelle Konten Reset", "~Switch", 37);
$this->RegisterVariableFloat("Aktuelle_Leistung_Batterien", "Aktuelle Leistung Batterien", "", 38);
$this->EnableAction("Virtual_Reset");
// Debug
$this->RegisterVariableString("CalcJSON", "Berechnung (JSON)", "", 99);
// Timer: Prefix/Funktionsname muss zu prefix in module.json passen
$this->RegisterTimer("UpdateTimer", 0, 'GEF_Update($_IPS["TARGET"]);');
}
public function ApplyChanges()
{
parent::ApplyChanges();
$intervalSec = (int)$this->ReadPropertyInteger("UpdateInterval");
$this->SetTimerInterval(
"UpdateTimer",
($intervalSec > 0) ? $intervalSec * 1000 : 0
);
// Bei Config-Änderung wird der statische Plan neu aufgebaut.
$this->BuildBatteryPlan(true);
// Kein Reset bei ApplyChanges.
// Nur Zeitstempel setzen, falls noch keiner vorhanden ist.
if ($this->GetBufferSafe("Int_LastTs") === "") {
$this->SetBuffer("Int_LastTs", (string)microtime(true));
}
//$this->Update();
}
public function RequestAction($Ident, $Value)
{
switch ($Ident) {
case "State":
SetValue($this->GetIDForIdent("State"), (bool)$Value);
if ((bool)$Value) {
$this->Update();
}
return;
case "Virtual_Reset":
if ((bool)$Value) {
$this->ResetVirtualAccounts();
SetValue($this->GetIDForIdent("Virtual_Reset"), false);
$this->Update();
} else {
SetValue($this->GetIDForIdent("Virtual_Reset"), false);
}
return;
}
throw new Exception("Invalid Ident: " . $Ident);
}
/*
* Update() ist die zentrale zyklische Berechnung.
*
* Ablauf:
* 1. Semaphore sichern, damit keine parallelen Updates laufen.
* 2. Prüfen, ob Modul aktiv ist.
* 3. Batterieplan laden/aktualisieren.
* 4. Sollwerte auf Batterien verteilen.
* 5. Aktuelle EV-/SDL-Leistung rückrechnen.
* 6. Virtuelle Konten integrieren.
* 7. Diagnosewerte schreiben.
*/
public function Update()
{
$semKey = 'BatEVSDL_Update_' . $this->InstanceID;
// Verhindert, dass ein Timerlauf den vorherigen überholt.
if (!IPS_SemaphoreEnter($semKey, 5000)) {
//$this->SendDebug("Update", "SKIP - Semaphore locked", 0);
return;
}
try {
if (!GetValue($this->GetIDForIdent("State"))) {
return;
}
// Baut den Plan nur neu, wenn sich relevante Config-Werte geändert haben.
$this->BuildBatteryPlan(false);
$plan = json_decode($this->GetBufferSafe("BatPlanJSON"), true);
// Reale SoC-Werte ändern sich laufend, daher werden Leistungsgrenzen aktualisiert.
$plan = $this->RefreshDynamicPlanValues($plan);
if (!is_array($plan) || empty($plan["bats"])) {
$this->WriteAllZero("plan empty/invalid");
return;
}
// Erst Sollwerte auf Batterien verteilen und aktuelle EV/SDL Leistung zurückrechnen.
$this->ApplySetpoints($plan);
// Danach EV und SDL virtuell integrieren.
$virt = $this->IntegrateVirtualAccounts($plan);
// Virtuelle Prozentwerte anzeigen.
$this->SetIdentValue("SDL_Pos", round($virt["SDL_pct"], 3));
$this->SetIdentValue("SoC_EV", round($virt["EV_pct"], 3));
// Aktuelle dynamische Maximalleistungen anzeigen.
$tot = $plan["total"] ?? [];
$this->SetIdentValue("P_SDL_laden", round((float)($tot["SDL_Charge_kW"] ?? 0.0) * 1000.0, 0));
$this->SetIdentValue("P_SDL_entladen", round((float)($tot["SDL_Discharge_kW"] ?? 0.0) * 1000.0, 0));
$this->SetIdentValue("P_EV_laden", (int)round((float)($tot["EV_Charge_kW"] ?? 0.0) * 1000.0, 0));
$this->SetIdentValue("P_EV_entladen", (int)round((float)($tot["EV_Discharge_kW"] ?? 0.0) * 1000.0, 0));
$calc = $plan;
$calc["virtual"] = $virt;
$calc["actual"] = [
"EV_W" => round((float)GetValue($this->GetIDForIdent("Aktuelle_Leistung_EV")), 0),
"SDL_W" => round((float)GetValue($this->GetIDForIdent("Aktuelle_Leistung_SDL")), 0)
];
$calcRounded = $this->RoundArray($calc, 3);
$this->SetIdentValue("CalcJSON", json_encode($calcRounded, JSON_PRETTY_PRINT));
// Reale Gesamtleistung aller Batterien anzeigen.
$totalBat = $this->GetTotalBatteryPowerIstW();
$this->SetIdentValue("Aktuelle_Leistung_Batterien", round($totalBat, 0));
} catch (Throwable $e) {
$this->SendDebug("Update ERROR", $e->getMessage() . " @ " . $e->getFile() . ":" . $e->getLine(), 0);
$this->WriteAllZero("Exception: " . $e->getMessage());
} finally {
IPS_SemaphoreLeave($semKey);
}
}
/**
* Baut den Batterieplan.
*
* Der Plan enthält pro Batterie:
* - Kapazität
* - maximale Leistung
* - reservierte SDL-Fenster unten/oben
* - nutzbares EV-Fenster
* - mögliche Lade-/Entladeleistungen
*
* Warum als Plan?
* Die statischen Werte müssen nicht in jedem Timerlauf komplett neu berechnet werden.
* Über einen Hash wird erkannt, ob die Konfiguration geändert wurde.
*/
private function BuildBatteryPlan(bool $force): void
{
$batteriesRaw = $this->ReadPropertyString("Batteries");
$sdlTotalW_laden = max(0, (int)$this->ReadPropertyInteger("SDL_Leistung_Laden"));
$sdlTotalW_entladen = max(0, (int)$this->ReadPropertyInteger("SDL_Leistung_Entladen"));
//Reserve Zeit, zb 30 oder 45 min
$reserveH = max(0.0, (float)$this->ReadPropertyFloat("ReserveHours"));
// Hash über alle statischen Eingaben.
// Wenn dieser gleich bleibt, muss der Plan nicht neu aufgebaut werden.
$hash = md5(json_encode([
"Batteries" => $batteriesRaw,
"SDL_W_Laden" => $sdlTotalW_laden,
"SDL_W_Entladen" => $sdlTotalW_entladen,
"ReserveHours" => $reserveH,
]));
if (!$force && $this->GetBufferSafe("BatPlanHash") === $hash) {
return;
}
$batteries = json_decode($batteriesRaw, true);
if (!is_array($batteries)) {
$batteries = [];
}
$sumBatPowerW = 0.0;
foreach ($batteries as $b) {
$p = (float)($b["powerbat"] ?? 0);
if ($p > 0) {
$sumBatPowerW += $p;
}
}
$plan = [
"inputs" => [
"SDL_Leistung_W_laden" => $sdlTotalW_laden,
"SDL_Leistung_W_entladen" => $sdlTotalW_entladen,
"ReserveHours" => $reserveH,
"SumBatPower_W" => round($sumBatPowerW, 0)
],
"bats" => [],
"total" => [
"SDL_kWh_total" => 0.0,
"EV_kWh_total" => 0.0,
"totalCap_kWh" => 0.0,
"SDL_Charge_kW" => 0.0,
"SDL_Discharge_kW" => 0.0,
"EV_Charge_kW" => 0.0,
"EV_Discharge_kW" => 0.0
]
];
if ($sumBatPowerW <= 0.0) {
$this->SetBuffer("BatPlanHash", $hash);
$this->SetBuffer("BatPlanJSON", json_encode($plan));
//$this->SendDebug("Plan", "sumBatPowerW=0 -> empty plan", 0);
return;
}
$sumBatPowerKW = $sumBatPowerW / 1000.0;
$sdlTotalKW_laden = $sdlTotalW_laden / 1000.0;
$sdlTotalKW_entladen = $sdlTotalW_entladen / 1000.0;
foreach ($batteries as $idx => $b) {
$pBatW = max(0.0, (float)($b["powerbat"] ?? 0));
$pBatKW = $pBatW / 1000.0;
$capKWh = max(0.0, (float)($b["capazity"] ?? 0));
// Physischer Mindest-SoC, unter den EV nicht entladen soll.
// SDL darf bis zu diesem Mindestwert herunter.
$minPhysicalSocPct = max(0.0, min(100.0, (float)($b["minPhysicalSocPct"] ?? 5.0)));
$minPhysicalKWh = $capKWh * $minPhysicalSocPct / 100.0;
if ($capKWh <= 0.0 || $pBatKW <= 0.0) {
continue;
}
$typ = (string)($b["typ"] ?? ("Bat " . ($idx + 1)));
$socVarId = (int)($b["soc"] ?? 0);
// SDL-Leistung wird proportional zur Batterie-Nennleistung verteilt.
$sdlShareKW_laden = ($sumBatPowerKW > 0.0) ? ($sdlTotalKW_laden / $sumBatPowerKW * $pBatKW) : 0.0;
$sdlShareKW_entladen = ($sumBatPowerKW > 0.0) ? ($sdlTotalKW_entladen / $sumBatPowerKW * $pBatKW) : 0.0;
// EV bekommt den Rest der Batterie-Leistung.
$evShareKW_laden = max(0.0, $pBatKW - $sdlShareKW_laden);
$evShareKW_entladen = max(0.0, $pBatKW - $sdlShareKW_entladen);
// SDL-Reserve in kWh.
$sdlLowerKWh = $sdlShareKW_entladen * $reserveH;
$sdlUpperKWh = $sdlShareKW_laden * $reserveH;
// EV darf nur zwischen underKWh und upKWh arbeiten.
// Darunter liegt SDL-Entlade-Reserve, darüber SDL-Lade-Reserve.
$underKWh = $minPhysicalKWh + $sdlLowerKWh;
$upKWh = $capKWh - $sdlUpperKWh;
// Grenzen sauber in die Batteriekapazität klemmen.
$underKWh = max($minPhysicalKWh, min($capKWh, $underKWh));
$upKWh = max($minPhysicalKWh, min($capKWh, $upKWh));
// Falls Reserve zu groß ist, EV-Fenster sauber auf 0 setzen.
if ($upKWh < $underKWh) {
$mid = $capKWh / 2.0;
$underKWh = $mid;
$upKWh = $mid;
}
$SDL_kWh = max(0.0, $sdlLowerKWh + $sdlUpperKWh);
$EV_kWh = max(0.0, $upKWh - $underKWh);
$realSocPct = $this->ReadSocPercent($socVarId);
$realKWh = $capKWh * $realSocPct / 100.0;
// Dynamische Freigaben auf Basis des realen SoC.
$canSDLDischarge =
($realKWh >= $minPhysicalKWh);
$canEVDischarge =
($realKWh > $underKWh);
$canEVCharge =
($realKWh < $upKWh);
$effectiveSDLChargeKW =
$sdlShareKW_laden;
$effectiveEVChargeKW =
$canEVCharge ? $evShareKW_laden : 0.0;
$effectiveSDLDischargeKW =
$canSDLDischarge ? $sdlShareKW_entladen : 0.0;
$effectiveEVDischargeKW =
$canEVDischarge ? $evShareKW_entladen : 0.0;
$bat = [
"idx" => $idx,
"typ" => $typ,
"socVarId" => $socVarId,
"capKWh" => $capKWh,
"pBatW" => $pBatW,
"minPhysicalSocPct" => $minPhysicalSocPct,
"minPhysicalKWh" => $minPhysicalKWh,
"sdlLowerKWh" => $sdlLowerKWh,
"sdlUpperKWh" => $sdlUpperKWh,
"underKWh" => $underKWh,
"upKWh" => $upKWh,
"SDL_kWh_total" => $SDL_kWh,
"EV_kWh_total" => $EV_kWh,
"sdlShareKW_laden" => $sdlShareKW_laden,
"sdlShareKW_entladen" => $sdlShareKW_entladen,
"evShareKW_laden" => $evShareKW_laden,
"evShareKW_entladen" => $evShareKW_entladen,
"real_SOC_pct" => round($realSocPct, 3),
"real_kWh" => round($realKWh, 3),
"SDL_Charge_kW" => $effectiveSDLChargeKW,
"SDL_Discharge_kW" => $effectiveSDLDischargeKW,
"EV_Charge_kW" => $effectiveEVChargeKW,
"EV_Discharge_kW" => $effectiveEVDischargeKW,
// Start-SoC für Sortier-/Verteillogik. Laufend wird später virtuell überschrieben.
"SDL_SOC" => 0.0,
"EV_SOC" => 0.0
];
$plan["bats"][] = $bat;
$plan["total"]["SDL_kWh_total"] += $SDL_kWh;
$plan["total"]["EV_kWh_total"] += $EV_kWh;
$plan["total"]["totalCap_kWh"] += $capKWh;
$plan["total"]["SDL_Charge_kW"] += $effectiveSDLChargeKW;
$plan["total"]["SDL_Discharge_kW"] += $effectiveSDLDischargeKW;
$plan["total"]["EV_Charge_kW"] += $effectiveEVChargeKW;
$plan["total"]["EV_Discharge_kW"] += $effectiveEVDischargeKW;
}
// Plan im Buffer speichern, damit Update() schnell darauf zugreifen kann.
$this->SetBuffer("BatPlanHash", $hash);
$this->SetBuffer("BatPlanJSON", json_encode($plan));
$this->SetBuffer("Int_CFG_HASH", $hash);
$this->SetIdentValue("SDL_Start_Pos", round((float)$this->ReadPropertyFloat("SDL_Start_Pos_Config"), 3));
$this->SetIdentValue("EV_Start_Pos", round((float)$this->ReadPropertyFloat("EV_Start_Pos_Config"), 3));
//$this->SendDebug("Plan", "Battery plan rebuilt (" . count($plan["bats"]) . " bats)", 0);
}
/**
* Setzt die virtuellen Konten auf die konfigurierten Startwerte.
* Wird über den WebFront-Schalter Virtual_Reset ausgelöst.
*/
public function ResetVirtualAccounts(): void
{
$this->BuildBatteryPlan(false);
$plan = json_decode($this->GetBufferSafe("BatPlanJSON"), true);
if (!is_array($plan)) {
return;
}
$sdlTotal = (float)($plan["total"]["SDL_kWh_total"] ?? 0.0);
$evTotal = (float)($plan["total"]["EV_kWh_total"] ?? 0.0);
$sdlStartPct = max(0.0, min(100.0, (float)$this->ReadPropertyFloat("SDL_Start_Pos_Config")));
$evStartPct = max(0.0, min(100.0, (float)$this->ReadPropertyFloat("EV_Start_Pos_Config")));
// Prozentwerte in kWh umrechnen und im Buffer speichern.
$this->SetBuffer("Int_E_SDL_kWh", (string)($sdlTotal * $sdlStartPct / 100.0));
$this->SetBuffer("Int_E_EV_kWh", (string)($evTotal * $evStartPct / 100.0));
$this->SetBuffer("Int_LastTs", (string)microtime(true));
$this->SetBuffer("Int_Init", "1");
$this->SetIdentValue("SDL_Start_Pos", round($sdlStartPct, 3));
$this->SetIdentValue("EV_Start_Pos", round($evStartPct, 3));
$this->SendDebug("Virtual_Reset", "SDL=" . round($sdlStartPct, 3) . "%, EV=" . round($evStartPct, 3) . "%", 0);
}
/**
* Integriert die virtuellen SDL-/EV-Konten.
*
* Formel grob:
* Energieänderung kWh = Leistung kW * Zeit h
*/
private function IntegrateVirtualAccounts(array $plan): array
{
$now = microtime(true);
$lastTs = (float)$this->GetBufferSafe("Int_LastTs");
if ($lastTs <= 0.0) {
$lastTs = $now;
}
$dtSec = $now - $lastTs;
if ($dtSec < 0.0) {
$dtSec = 0.0;
}
if ($dtSec > 10.0) {
$dtSec = 10.0;
}
$dtH = $dtSec / 3600.0;
$eSDL = (float)$this->GetBufferSafe("Int_E_SDL_kWh");
$eEV = (float)$this->GetBufferSafe("Int_E_EV_kWh");
$pSDL = (float)GetValue($this->GetIDForIdent("Aktuelle_Leistung_SDL"));
$pEV = (float)GetValue($this->GetIDForIdent("Aktuelle_Leistung_EV"));
$sdlTotal = (float)($plan["total"]["SDL_kWh_total"] ?? 0.0);
$evTotal = (float)($plan["total"]["EV_kWh_total"] ?? 0.0);
if (
$this->GetBufferSafe("Int_Init") !== "1" ||
$this->GetBufferSafe("Int_E_SDL_kWh") === "" ||
$this->GetBufferSafe("Int_E_EV_kWh") === ""
) {
$sdlStartPct = max(0.0, min(100.0, (float)$this->ReadPropertyFloat("SDL_Start_Pos_Config")));
$evStartPct = max(0.0, min(100.0, (float)$this->ReadPropertyFloat("EV_Start_Pos_Config")));
$lastSdlPct = (float)GetValue($this->GetIDForIdent("SDL_Pos"));
$lastEvPct = (float)GetValue($this->GetIDForIdent("SoC_EV"));
$lastSdlPct = max(0.0, min(100.0, $lastSdlPct));
$lastEvPct = max(0.0, min(100.0, $lastEvPct));
$eSDL = ($sdlTotal > 0.0) ? $sdlTotal * $lastSdlPct / 100.0 : 0.0;
$eEV = ($evTotal > 0.0) ? $evTotal * $lastEvPct / 100.0 : 0.0;
$this->MaybeRecalculateEVFromPhysical($plan, $eSDL, $eEV, $sdlTotal, $evTotal);
$this->SetBuffer("Int_E_SDL_kWh", (string)$eSDL);
$this->SetBuffer("Int_E_EV_kWh", (string)$eEV);
$this->SetBuffer("Int_LastTs", (string)$now);
$this->SetBuffer("Int_Init", "1");
$lastTs = $now;
$dtSec = 0.0;
$dtH = 0.0;
}
$maxSDLChargeW = (float)($plan["total"]["SDL_Charge_kW"] ?? 0.0) * 1000.0;
$maxSDLDisW = (float)($plan["total"]["SDL_Discharge_kW"] ?? 0.0) * 1000.0;
$maxEVChargeW = (float)($plan["total"]["EV_Charge_kW"] ?? 0.0) * 1000.0;
$maxEVDisW = (float)($plan["total"]["EV_Discharge_kW"] ?? 0.0) * 1000.0;
$pSDL = max(-$maxSDLDisW, min($maxSDLChargeW, $pSDL));
$pEV = max(-$maxEVDisW, min($maxEVChargeW, $pEV));
if (abs($pSDL) > 1.0) {
$eSDL += ($pSDL / 1000.0) * $dtH;
}
if (abs($pEV) > 1.0) {
$eEV += ($pEV / 1000.0) * $dtH;
}
$eSDL = ($sdlTotal > 0.0) ? max(0.0, min($sdlTotal, $eSDL)) : 0.0;
$eEV = ($evTotal > 0.0) ? max(0.0, min($evTotal, $eEV)) : 0.0;
$this->SetBuffer("Int_E_SDL_kWh", (string)$eSDL);
$this->SetBuffer("Int_E_EV_kWh", (string)$eEV);
$this->SetBuffer("Int_LastTs", (string)$now);
return [
"SDL_kWh" => round($eSDL, 6),
"EV_kWh" => round($eEV, 6),
"SDL_pct" => ($sdlTotal > 0.0) ? max(0.0, min(100.0, $eSDL / $sdlTotal * 100.0)) : 0.0,
"EV_pct" => ($evTotal > 0.0) ? max(0.0, min(100.0, $eEV / $evTotal * 100.0)) : 0.0,
"SDL_Ist_W_used" => round($pSDL, 0),
"EV_Ist_W_used" => round($pEV, 0),
"dtSec" => round($dtSec, 3)
];
}
/**
* Liest EV-/SDL-Sollwerte, verteilt sie auf die Batterien und schreibt die Register.
*/
public function ApplySetpoints(?array $plan = null): void
{
if ($plan === null) {
$plan = json_decode($this->GetBufferSafe("BatPlanJSON"), true);
}
if (!is_array($plan)) {
return;
}
$pEvW = (float)GetValue($this->GetIDForIdent("Nennleistung_Soll_EV"));
$pSdlW = (float)GetValue($this->GetIDForIdent("Nennleistung_Soll_SDL"));
$distribution = $this->CalculateBatteryDistribution($pEvW, $pSdlW, $plan);
$this->WriteBatteryPowerSetpoints($distribution);
// Nach dem Schreiben wird aus der realen Batterie-Istleistung berechnet,
// welcher Anteil EV und welcher SDL zugeordnet wird.
$this->UpdateActualPowerSplit($pEvW, $pSdlW);
}
/**
* Verteilt die gewünschte EV- und SDL-Leistung auf einzelne Batterien.
*
* Dabei wird geladen bevorzugt bei niedrigem virtuellem SoC,
* entladen bevorzugt bei hohem virtuellem SoC.
*
* Einschränkung:
* Da aktuell globaler virtueller SoC verwendet wird, haben alle Batterien denselben
* virtuellen SoC. Die Sortierung bringt deshalb nur begrenzt einen Effekt.
*/
private function CalculateBatteryDistribution(float $pEvW, float $pSdlW, array $plan): array
{
$batteries = $plan["bats"] ?? [];
if (empty($batteries)) {
return [];
}
$virtSDL = (float)$this->GetBufferSafe("Int_E_SDL_kWh");
$virtEV = (float)$this->GetBufferSafe("Int_E_EV_kWh");
$sdlTotal = (float)($plan["total"]["SDL_kWh_total"] ?? 0.0);
$evTotal = (float)($plan["total"]["EV_kWh_total"] ?? 0.0);
$globalSDLpct = ($sdlTotal > 0.0) ? max(0.0, min(100.0, $virtSDL / $sdlTotal * 100.0)) : 0.0;
$globalEVpct = ($evTotal > 0.0) ? max(0.0, min(100.0, $virtEV / $evTotal * 100.0)) : 0.0;
foreach ($batteries as &$bat) {
// Mit globalem virtuellem SoC sortieren. Bei Bedarf später pro Batterie virtualisieren.
$bat["SDL_SOC"] = $globalSDLpct;
$bat["EV_SOC"] = $globalEVpct;
}
unset($bat);
$distributePower = function (float $targetPower, string $mode) use ($batteries): array {
$result = [];
foreach ($batteries as $bat) {
$result[$bat["idx"]] = 0.0;
}
if (abs($targetPower) < 0.01) {
return $result;
}
$isCharge = ($targetPower > 0.0);
$absPower = abs($targetPower);
$socKey = ($mode === "EV") ? "EV_SOC" : "SDL_SOC";
$limitKey = $isCharge ? $mode . "_Charge_kW" : $mode . "_Discharge_kW";
$groups = [];
foreach ($batteries as $bat) {
$soc = (int)round((float)($bat[$socKey] ?? 0.0));
$maxW = max(0.0, (float)($bat[$limitKey] ?? 0.0) * 1000.0);
$groups[$soc][] = [
"idx" => $bat["idx"],
"maxW" => $maxW
];
}
if ($isCharge) {
ksort($groups);
} else {
krsort($groups);
}
$remaining = $absPower;
foreach ($groups as $groupBatteries) {
if ($remaining <= 0.01) {
break;
}
$groupTotal = 0.0;
foreach ($groupBatteries as $gb) {
$groupTotal += $gb["maxW"];
}
if ($groupTotal <= 0.0) {
continue;
}
// Innerhalb einer Gruppe proportional zur möglichen Leistung verteilen.
$powerForGroup = min($remaining, $groupTotal);
$ratio = $powerForGroup / $groupTotal;
foreach ($groupBatteries as $gb) {
$result[$gb["idx"]] = $gb["maxW"] * $ratio;
}
$remaining -= $powerForGroup;
}
if (!$isCharge) {
foreach ($result as $idx => $val) {
$result[$idx] = -$val;
}
}
return $result;
};
$evDistribution = $distributePower($pEvW, "EV");
$sdlDistribution = $distributePower($pSdlW, "SDL");
$finalOutput = [];
foreach ($batteries as $bat) {
$idx = $bat["idx"];
$totalW = ($evDistribution[$idx] ?? 0.0) + ($sdlDistribution[$idx] ?? 0.0);
$finalOutput[] = [
"idx" => $idx,
"typ" => (string)$bat["typ"],
"chargeW" => ($totalW > 0.0) ? round(abs($totalW), 0) : 0,
"dischargeW" => ($totalW < 0.0) ? round(abs($totalW), 0) : 0
];
}
return $finalOutput;
}
/**
* Rechnet die reale Batterie-Gesamtleistung auf EV und SDL zurück.
*
* Beispiel:
* EV-Soll = 3000 W, SDL-Soll = 1000 W, Summe = 4000 W.
* Batterie liefert real nur 3600 W.
* Dann wird EV = 2700 W, SDL = 900 W angenommen.
*/
private function UpdateActualPowerSplit(float $pEvW, float $pSdlW): void
{
$totalPowerIst = $this->GetTotalBatteryPowerIstW();
$eps = 0.01;
$sumSoll = $pEvW + $pSdlW;
if (abs($sumSoll) > $eps) {
$factor = $totalPowerIst / $sumSoll;
$rawEV = $pEvW * $factor;
$rawSDL = $pSdlW * $factor;
} else {
// Wenn kein Sollwert vorhanden ist, nicht plötzlich auf Soll springen.
// Stattdessen letzten gefilterten Wert verwenden.
$rawEV = (float)$this->GetBufferSafe("CUR_EV_VAL");
$rawSDL = (float)$this->GetBufferSafe("CUR_SDL_VAL");
}
$filterAktiv = $this->ReadPropertyBoolean("FilterAktiv");
if ($filterAktiv) {
$tolPct = max(0.0, (float)$this->ReadPropertyFloat("FilterTolerancePct")) / 100.0;
$needHits = max(1, (int)$this->ReadPropertyInteger("FilterHits"));
$fEV = $this->FilterCurrent("EV", $rawEV, $pEvW, abs($pEvW) * $tolPct, $needHits);
$fSDL = $this->FilterCurrent("SDL", $rawSDL, $pSdlW, abs($pSdlW) * $tolPct, $needHits);
} else {
$fEV = $rawEV;
$fSDL = $rawSDL;
$this->ClearCurrentFilterBuffers();
}
$this->SetIdentValue("Aktuelle_Leistung_EV", (float)$fEV);
$this->SetIdentValue("Aktuelle_Leistung_SDL", (float)$fSDL);
}
/**
* Filtert die rückgerechnete Istleistung.
*
* Zweck:
* - Fehlmessungen abfangen
* - träge Batterieantwort glätten
* - harte Sprünge vermeiden
* - falsche Vorzeichen ignorieren
*/
private function FilterCurrent(string $ch, float $raw, float $target, float $tolW, int $needHits): float
{
$lastVal = (float)$this->GetBufferSafe("CUR_{$ch}_VAL");
$pending = (float)$this->GetBufferSafe("CUR_{$ch}_PEND");
$hits = (int)$this->GetBufferSafe("CUR_{$ch}_HITS");
$lastTs = (float)$this->GetBufferSafe("CUR_{$ch}_TS");
$now = microtime(true);
if ($this->GetBufferSafe("CUR_{$ch}_INIT") !== "1") {
$lastVal = $raw;
$pending = $target;
$hits = 0;
$lastTs = $now;
$this->SetBuffer("CUR_{$ch}_INIT", "1");
$this->SetBuffer("CUR_{$ch}_VAL", (string)$lastVal);
$this->SetBuffer("CUR_{$ch}_PEND", (string)$pending);
$this->SetBuffer("CUR_{$ch}_HITS", (string)$hits);
$this->SetBuffer("CUR_{$ch}_TS", (string)$lastTs);
return $lastVal;
}
$dt = max(0.0, min(5.0, $now - $lastTs));
$this->SetBuffer("CUR_{$ch}_TS", (string)$now);
$maxRampWPerSec = max(100.0, (float)$this->ReadPropertyFloat("FilterRampWPerSec"));
if (abs($target) < 0.5) {
$lastVal = $this->MoveTowards($lastVal, 0.0, $maxRampWPerSec * $dt);
if (abs($lastVal) < 1.0) {
$lastVal = 0.0;
}
$this->SetBuffer("CUR_{$ch}_VAL", (string)$lastVal);
$this->SetBuffer("CUR_{$ch}_PEND", (string)$target);
$this->SetBuffer("CUR_{$ch}_HITS", "0");
return $lastVal;
}
if (abs($target - $pending) > 0.5) {
$pending = $target;
$hits = 0;
}
if (($pending > 0.0 && $raw < -50.0) || ($pending < 0.0 && $raw > 50.0)) {
$this->SetBuffer("CUR_{$ch}_VAL", (string)$lastVal);
$this->SetBuffer("CUR_{$ch}_PEND", (string)$pending);
$this->SetBuffer("CUR_{$ch}_HITS", "0");
return $lastVal;
}
$tolW = max(100.0, $tolW);
if (abs($raw - $pending) <= $tolW) {
$hits++;
if ($hits >= $needHits) {
$lastVal = $this->MoveTowards($lastVal, $raw, $maxRampWPerSec * $dt);
$hits = 0;
}
} else {
// Rohwert passt nicht zum Sollwert:
// NICHT Richtung Soll laufen, sondern alten Wert halten.
$hits = 0;
}
if (abs($lastVal) < 1.0) {
$lastVal = 0.0;
}
$this->SetBuffer("CUR_{$ch}_VAL", (string)$lastVal);
$this->SetBuffer("CUR_{$ch}_PEND", (string)$pending);
$this->SetBuffer("CUR_{$ch}_HITS", (string)$hits);
return $lastVal;
}
/**
* Löscht Filterzustände, wenn der Filter deaktiviert wird.
*/
private function ClearCurrentFilterBuffers(): void
{
foreach (["EV", "SDL"] as $ch) {
$this->SetBuffer("CUR_{$ch}_INIT", "");
$this->SetBuffer("CUR_{$ch}_VAL", "");
$this->SetBuffer("CUR_{$ch}_PEND", "");
$this->SetBuffer("CUR_{$ch}_HITS", "");
$this->SetBuffer("CUR_{$ch}_TS", "");
}
}
/**
* Schreibt die berechneten Lade-/Entladeleistungen in die Batterie-Register.
*/
private function WriteBatteryPowerSetpoints(array $distribution): void
{
//IPS_LogMessage(__FUNCTION__, "distribution=" . json_encode($distribution, JSON_PRETTY_PRINT));
$batteriesCfg = json_decode($this->ReadPropertyString("Batteries"), true);
if (!is_array($batteriesCfg) || empty($batteriesCfg)) {
return;
}
foreach ($distribution as $d) {
$idx = (int)($d["idx"] ?? -1);
if ($idx < 0 || !isset($batteriesCfg[$idx])) {
continue;
}
$cfg = $batteriesCfg[$idx];
$typ = (string)($d["typ"] ?? ($cfg["typ"] ?? ("Bat " . ($idx + 1))));
$chargeW = max(0.0, (float)($d["chargeW"] ?? 0.0));
$dischargeW = max(0.0, (float)($d["dischargeW"] ?? 0.0));
if ($chargeW > 0.0 && $dischargeW > 0.0) {
//$this->SendDebug("WriteBatteryPowerSetpoints", "WARN both >0 for $typ idx=$idx", 0);
$chargeW = 0.0;
$dischargeW = 0.0;
}
$this->WriteByVendorRegistersSingleMode($typ, $cfg, $chargeW, $dischargeW);
//$this->SendDebug("Setpoints", "$typ idx=$idx charge={$chargeW}W discharge={$dischargeW}W", 0);
}
}
/**
* Schreibt herstellerspezifische Register.
*
* Je nach Batterie/Wechselrichter sind unterschiedliche Moduswerte nötig.
* GoodWe und SolarEdge werden hier speziell behandelt.
*/
private function WriteByVendorRegistersSingleMode(string $typ, array $cfg, float $chargeW, float $dischargeW): void
{
$t = mb_strtolower($typ);
$varPowerCharge = (int)($cfg["powerbat_laden"] ?? 0);
$varPowerDisch = (int)($cfg["powerbat_entladen"] ?? 0);
$varMode = (int)($cfg["register_ladenentladen_modus"] ?? 0);
/*
$setInt = function (int $varId, int $value): void {
if ($varId > 0 && IPS_VariableExists($varId)) {
RequestAction($varId, $value);
}
};
$setW = function (int $varId, float $w): void {
if ($varId > 0 && IPS_VariableExists($varId)) {
RequestAction($varId, (int)round(max(0.0, $w), 0));
}
};
*/
$setInt = function (int $varId, int $value): void {
if ($varId > 0 && IPS_VariableExists($varId)) {
$old = (int)GetValue($varId);
if ($old !== $value) {
RequestAction($varId, $value);
}
}
};
$setW = function (int $varId, float $w): void {
if ($varId > 0 && IPS_VariableExists($varId)) {
$new = (int)round(max(0.0, $w), 0);
$old = (int)GetValue($varId);
if (abs($old - $new) > 50) {
RequestAction($varId, $new);
}
}
};
$modeCharge = 1;
$modeDisch = 2;
if (strpos($t, "goodwe") !== false) {
$modeCharge = 11;
$modeDisch = 12;
} elseif (strpos($t, "solaredge") !== false) {
$modeCharge = 3;
$modeDisch = 4;
}
if ($chargeW > 0.0) {
$setInt($varMode, $modeCharge);
if (strpos($t, "goodwe") !== false) {
$setW($varPowerCharge, $chargeW);
$setW($varPowerDisch, 0.0);
return;
}
$setW($varPowerCharge, $chargeW);
$setW($varPowerDisch, 0.0);
return;
}
if ($dischargeW > 0.0) {
$setInt($varMode, $modeDisch);
if (strpos($t, "goodwe") !== false) {
$setW($varPowerCharge, $dischargeW);
$setW($varPowerDisch, 0.0);
return;
}
$setW($varPowerDisch, $dischargeW);
$setW($varPowerCharge, 0.0);
return;
}
$setW($varPowerCharge, 0.0);
$setW($varPowerDisch, 0.0);
}
/**
* Summiert die reale Batterie-Istleistung.
*
* Die Multiplikation mit -1 dreht das Vorzeichen.
* Offenbar liefern die Batterie-Register ein umgekehrtes Vorzeichen.
*/
private function GetTotalBatteryPowerIstW(): float
{
$cfg = json_decode($this->ReadPropertyString("Batteries"), true);
if (!is_array($cfg)) {
return 0.0;
}
$sum = 0.0;
foreach ($cfg as $b) {
$varId = (int)($b["register_bat_power"] ?? 0);
if ($varId > 0 && IPS_VariableExists($varId)) {
$sum += (-1.0) * (float)GetValue($varId);
}
}
return $sum;
}
/**
* Liest einen SoC-Wert.
*
* Spezialfall:
* Wenn in der Config direkt eine Zahl von 0 bis 100 steht und keine IPS-Variable
* mit dieser ID existiert, wird diese Zahl als fester SoC interpretiert.
*/
private function ReadSocPercent(int $varId): float
{
if ($varId >= 0 && $varId <= 100 && !IPS_VariableExists($varId)) {
return (float)$varId;
}
if ($varId <= 0 || !IPS_VariableExists($varId)) {
return 0.0;
}
$v = GetValue($varId);
if (!is_numeric($v)) {
return 0.0;
}
return max(0.0, min(100.0, (float)$v));
}
/**
* Sichere Buffer-Lesefunktion.
* Gibt immer einen String zurück, damit Casts kontrolliert funktionieren.
*/
private function GetBufferSafe(string $name): string
{
$v = $this->GetBuffer($name);
return is_string($v) ? $v : "";
}
/**
* Schreibt bei Fehlern alle relevanten Ausgabewerte auf 0.
*/
private function WriteAllZero(string $reason): void
{
$this->SetIdentValue("SDL_Pos", 0.0);
$this->SetIdentValue("SoC_EV", 0.0);
$this->SetIdentValue("P_SDL_laden", 0.0);
$this->SetIdentValue("P_SDL_entladen", 0.0);
$this->SetIdentValue("P_EV_laden", 0);
$this->SetIdentValue("P_EV_entladen", 0);
$this->SetIdentValue("CalcJSON", json_encode(["error" => $reason], JSON_PRETTY_PRINT));
}
/**
* Rundet rekursiv alle Float-Werte in Arrays.
* Wird nur für die lesbare JSON-Diagnose verwendet.
*/
private function RoundArray($data, int $decimals = 3)
{
if (is_array($data)) {
foreach ($data as $k => $v) {
$data[$k] = $this->RoundArray($v, $decimals);
}
return $data;
}
if (is_float($data)) {
return round($data, $decimals);
}
return $data;
}
/**
* Bewegt einen Wert begrenzt in Richtung Zielwert.
* Wird im Filter verwendet, damit Werte nicht sprunghaft wechseln.
*/
private function MoveTowards(float $current, float $target, float $maxStep): float
{
$maxStep = max(0.0, $maxStep);
if (abs($target - $current) <= $maxStep) {
return $target;
}
return $current + (($target > $current) ? $maxStep : -$maxStep);
}
/**
* Synchronisiert das virtuelle EV-Konto gelegentlich mit dem realen Batterie-SoC.
*
* Hintergrund:
* Das virtuelle EV-Konto wird normalerweise nur über Leistung × Zeit integriert.
* Dadurch können mit der Zeit Abweichungen zur realen Batterie entstehen
* (z.B. durch Messfehler, Begrenzungen, Verluste oder Neustarts).
*
* Diese Methode berechnet deshalb den EV-Anteil neu aus den echten Batterie-SoC-Werten.
*
* Wichtig:
* Die Neuberechnung erfolgt nur:
* - alle X Stunden
* - wenn SDL genau bei 50% steht (Neutralpunkt)
* - und nur wenn die Abweichung gross genug ist
*
* Ziel:
* Das virtuelle EV-Konto langfristig wieder an die reale Batterie angleichen.
*/
private function MaybeRecalculateEVFromPhysical(array $plan, float $eSDL, float &$eEV, float $sdlTotal, float $evTotal): void
{
if ($evTotal <= 0.0 || $sdlTotal <= 0.0) {
return;
}
$intervalHours = max(0.0, (float)$this->ReadPropertyFloat("EV_Recalc_IntervalHours"));
if ($intervalHours <= 0.0) {
return;
}
$now = microtime(true);
$lastCheckTs = (float)$this->GetBufferSafe("EV_Recalc_LastCheckTs");
// Nur alle X Stunden prüfen
if (
$lastCheckTs > 0.0 &&
($now - $lastCheckTs) < ($intervalHours * 3600.0)
) {
return;
}
$this->SetBuffer("EV_Recalc_LastCheckTs", (string)$now);
// SDL muss bei 50% sein
$sdlPct = ($eSDL / $sdlTotal) * 100.0;
if (abs($sdlPct - 50.0) > 0.001) {
$this->SendDebug(
"EV_Recalc",
"Skip: SDL nicht bei 50%, aktuell=" . round($sdlPct, 3) . "%",
0
);
return;
}
$newEVkWh = 0.0;
foreach (($plan["bats"] ?? []) as $bat) {
$capKWh = (float)($bat["capKWh"] ?? 0.0);
$socVarId = (int)($bat["socVarId"] ?? 0);
$realSocPct = $this->ReadSocPercent($socVarId);
$realKWh = $capKWh * $realSocPct / 100.0;
$underKWh = (float)($bat["underKWh"] ?? 0.0);
// Maximales EV Fenster dieser Batterie
$evBatTotal = (float)($bat["EV_kWh_total"] ?? 0.0);
// EV Anteil relativ zur unteren Grenze
$evPart = $realKWh - $underKWh;
// Begrenzung:
// unter underKWh => 0
// über upKWh => max EV Fenster
$evPart = max(0.0, min($evBatTotal, $evPart));
$newEVkWh += $evPart;
}
// Global begrenzen
$newEVkWh = max(0.0, min($evTotal, $newEVkWh));
$newEVpct = ($evTotal > 0.0)
? ($newEVkWh / $evTotal * 100.0)
: 0.0;
$oldEVpct = ($evTotal > 0.0)
? ($eEV / $evTotal * 100.0)
: 0.0;
$tolPct = max(0.0, (float)$this->ReadPropertyFloat("EV_Recalc_TolerancePct"));
// Nur übernehmen wenn Differenz gross genug
if (abs($newEVpct - $oldEVpct) <= $tolPct) {
$this->SendDebug(
"EV_Recalc",
"Skip: Differenz innerhalb Toleranz. Alt=" .
round($oldEVpct, 3) .
"% Neu=" .
round($newEVpct, 3) .
"%",
0
);
return;
}
$eEV = $newEVkWh;
$this->SendDebug(
"EV_Recalc",
"EV neu berechnet. Alt=" .
round($oldEVpct, 3) .
"% Neu=" .
round($newEVpct, 3) .
"%",
0
);
}
/**
* Aktualisiert die dynamischen Werte im Plan anhand des realen Batterie-SoC.
*
* Warum nötig?
* BuildBatteryPlan() baut hauptsächlich statische Grenzen.
* Der echte Batterie-SoC ändert sich aber laufend.
* Deshalb werden Lade-/Entladefreigaben hier bei jedem Update erneuert.
*/
private function RefreshDynamicPlanValues(array $plan): array
{
foreach ($plan["bats"] as &$bat) {
$socVarId = (int)($bat["socVarId"] ?? 0);
$capKWh = (float)($bat["capKWh"] ?? 0.0);
$realSocPct = $this->ReadSocPercent($socVarId);
$realKWh = $capKWh * $realSocPct / 100.0;
$minPhysicalKWh = (float)($bat["minPhysicalKWh"] ?? 0.0);
$underKWh = (float)($bat["underKWh"] ?? 0.0);
$upKWh = (float)($bat["upKWh"] ?? 0.0);
$bat["real_SOC_pct"] = round($realSocPct, 3);
$bat["real_kWh"] = round($realKWh, 3);
$bat["SDL_Discharge_kW"] =
($realKWh >= $minPhysicalKWh)
? (float)$bat["sdlShareKW_entladen"]
: 0.0;
$bat["EV_Discharge_kW"] =
($realKWh > $underKWh)
? (float)$bat["evShareKW_entladen"]
: 0.0;
$bat["EV_Charge_kW"] =
($realKWh < $upKWh)
? (float)$bat["evShareKW_laden"]
: 0.0;
$bat["SDL_Charge_kW"] =
(float)$bat["sdlShareKW_laden"];
}
unset($bat);
$plan["total"]["SDL_Charge_kW"] = 0.0;
$plan["total"]["SDL_Discharge_kW"] = 0.0;
$plan["total"]["EV_Charge_kW"] = 0.0;
$plan["total"]["EV_Discharge_kW"] = 0.0;
foreach ($plan["bats"] as $bat) {
$plan["total"]["SDL_Charge_kW"] += (float)$bat["SDL_Charge_kW"];
$plan["total"]["SDL_Discharge_kW"] += (float)$bat["SDL_Discharge_kW"];
$plan["total"]["EV_Charge_kW"] += (float)$bat["EV_Charge_kW"];
$plan["total"]["EV_Discharge_kW"] += (float)$bat["EV_Discharge_kW"];
}
return $plan;
}
/**
* Hilfsfunktion zum sicheren Schreiben einer Instanzvariable per Ident.
* Wenn die Variable nicht existiert, wird still abgebrochen.
*/
private function SetIdentValue(string $ident, $value): void
{
$id = @$this->GetIDForIdent($ident);
if ($id <= 0) {
//$this->SendDebug(__FUNCTION__, "Ident nicht gefunden: $ident", 0);
return;
}
SetValue($id, $value);
}
}
?>