1255 lines
44 KiB
PHP
1255 lines
44 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
|
||
?>
|