Files
Enelix-Utils/VirtuelleBatterie/module.php
T
2026-09-27 12:28:32 +00:00

782 lines
26 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/VirtuelleBatterieRegler.php';
use Belevo\EnelixUtils\VirtuelleBatterieRegler;
class VirtuelleBatterie extends IPSModule
{
private const STATUS_AKTIV = 102;
private const STATUS_INAKTIV = 104;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_MESSWERTE_UNGUELTIG = 202;
private const STATUS_AUSGABE_FEHLER = 203;
private const VM_UPDATE = 10603;
public function Create(): void
{
parent::Create();
$this->RegisterPropertyString('Batterieliste', '[]');
$this->RegisterPropertyInteger('SDLReserveLaden', 0);
$this->RegisterPropertyInteger('SDLReserveEntladen', 0);
$this->RegisterPropertyFloat('Reservezeit', 0.5);
$this->RegisterPropertyInteger('Aktualisierungsintervall', 2);
$this->RegisterPropertyInteger('MesswertMaxAlter', 30);
$this->RegisterPropertyInteger('VorgabeTimeout', 30);
$this->RegisterPropertyFloat('EigenverbrauchStart', 50.0);
$this->RegisterPropertyFloat('SDLStart', 50.0);
$this->RegisterPropertyBoolean('DiagnoseAnzeigen', false);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
$this->RegisterVariableInteger(
'EVManagement',
'EV Managementregister',
'',
20
);
$this->EnableAction('EVManagement');
$this->RegisterVariableInteger('EVModus', 'EV Modusregister', '', 30);
$this->EnableAction('EVModus');
$this->RegisterVariableFloat(
'EVLadeleistung',
'EV Laderegister',
'',
40
);
$this->EnableAction('EVLadeleistung');
$this->RegisterVariableFloat(
'EVEntladeleistung',
'EV Entladeregister',
'',
50
);
$this->EnableAction('EVEntladeleistung');
$this->RegisterVariableFloat(
'SDLSollleistung',
'SDL Sollleistung',
'',
60
);
$this->EnableAction('SDLSollleistung');
$this->RegisterVariableFloat(
'GesamtLadezustand',
'Gesamtladezustand',
'',
100
);
$this->RegisterVariableFloat(
'GesamtIstleistung',
'Gesamte Istleistung',
'',
110
);
$this->RegisterVariableFloat(
'GesamtMaxLaden',
'Gesamte maximale Ladeleistung',
'',
120
);
$this->RegisterVariableFloat(
'GesamtMaxEntladen',
'Gesamte maximale Entladeleistung',
'',
130
);
$this->RegisterVariableFloat(
'EigenverbrauchLadezustand',
'Eigenverbrauch Ladezustand',
'',
200
);
$this->RegisterVariableFloat(
'EigenverbrauchSollleistung',
'Eigenverbrauch Sollleistung',
'',
210
);
$this->RegisterVariableFloat(
'EigenverbrauchIstleistung',
'Eigenverbrauch Istleistung',
'',
220
);
$this->RegisterVariableFloat(
'EigenverbrauchMaxLaden',
'Eigenverbrauch maximal Laden',
'',
230
);
$this->RegisterVariableFloat(
'EigenverbrauchMaxEntladen',
'Eigenverbrauch maximal Entladen',
'',
240
);
$this->RegisterVariableFloat(
'SDLLadezustand',
'SDL Ladezustand',
'',
300
);
$this->RegisterVariableFloat(
'SDLIstleistung',
'SDL Istleistung',
'',
310
);
$this->RegisterVariableFloat(
'SDLMaxLaden',
'SDL maximal Laden',
'',
320
);
$this->RegisterVariableFloat(
'SDLMaxEntladen',
'SDL maximal Entladen',
'',
330
);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 400);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 410);
$this->RegisterVariableString(
'Verteilstatus',
'Verteilstatus',
'',
420
);
$this->RegisterAttributeString('RegistrierteMesswerte', '[]');
$this->RegisterAttributeString('LetzteZielwerte', '{}');
$this->RegisterAttributeFloat('EigenverbrauchEnergie_kWh', 0.0);
$this->RegisterAttributeFloat('SDLEnergie_kWh', 0.0);
$this->RegisterAttributeBoolean('KontenInitialisiert', false);
$this->RegisterAttributeInteger('LetzteIntegration', 0);
$this->RegisterAttributeInteger('LetzteEVVorgabe', 0);
$this->RegisterAttributeInteger('LetzteSDLVorgabe', 0);
$this->RegisterAttributeFloat('EigenverbrauchAuftrag_W', 0.0);
$this->RegisterTimer(
'Aktualisierung',
0,
'IPS_RequestAction($_IPS["TARGET"], "Aktualisieren", 0);'
);
$this->RegisterTimer(
'EVUebernahme',
0,
'IPS_RequestAction($_IPS["TARGET"], "EVUebernehmen", 0);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
$this->SetTimerInterval('Aktualisierung', 0);
$this->SetTimerInterval('EVUebernahme', 0);
$this->registriereMesswertmeldungen();
try {
$this->pruefeKonfiguration();
$this->SetTimerInterval(
'Aktualisierung',
$this->ReadPropertyInteger('Aktualisierungsintervall') * 1000
);
$this->aktualisiere();
} catch (Throwable $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
}
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $nachricht !== self::VM_UPDATE) {
return;
}
$ids = json_decode(
$this->ReadAttributeString('RegistrierteMesswerte'),
true
);
if (is_array($ids) && in_array((int) $senderID, $ids, true)) {
$this->aktualisiere();
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
if (!(bool) $wert) {
$this->WriteAttributeFloat('EigenverbrauchAuftrag_W', 0.0);
$this->SetValue('SDLSollleistung', 0.0);
$this->sichererZustand('');
$this->SetStatus(self::STATUS_INAKTIV);
return;
}
$this->aktualisiere();
return;
case 'EVManagement':
$this->SetValue('EVManagement', (int) $wert);
$this->uebernehmeEVVorgabe();
return;
case 'EVModus':
$this->SetValue('EVModus', (int) $wert);
$this->planeEVUebernahme();
return;
case 'EVLadeleistung':
$this->SetValue(
'EVLadeleistung',
max(0.0, (float) $wert)
);
$this->planeEVUebernahme();
return;
case 'EVEntladeleistung':
$this->SetValue(
'EVEntladeleistung',
max(0.0, (float) $wert)
);
$this->planeEVUebernahme();
return;
case 'SDLSollleistung':
$this->SetValue('SDLSollleistung', (float) $wert);
$this->WriteAttributeInteger('LetzteSDLVorgabe', time());
$this->aktualisiere();
return;
case 'EVUebernehmen':
$this->SetTimerInterval('EVUebernahme', 0);
$this->uebernehmeEVVorgabe();
return;
case 'Aktualisieren':
$this->aktualisiere();
return;
case 'KontenReset':
$this->WriteAttributeBoolean('KontenInitialisiert', false);
$this->WriteAttributeInteger('LetzteIntegration', time());
$this->aktualisiere();
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
private function planeEVUebernahme(): void
{
$this->SetTimerInterval('EVUebernahme', 50);
}
private function uebernehmeEVVorgabe(): void
{
$management = (int) $this->GetValue('EVManagement');
$modus = (int) $this->GetValue('EVModus');
$ziel = 0.0;
if ($management === 1) {
$ziel = $modus === 1
? -(float) $this->GetValue('EVEntladeleistung')
: (float) $this->GetValue('EVLadeleistung');
}
$this->WriteAttributeFloat('EigenverbrauchAuftrag_W', $ziel);
$this->WriteAttributeInteger('LetzteEVVorgabe', time());
$this->SetValue('EigenverbrauchSollleistung', $ziel);
$this->aktualisiere();
}
private function aktualisiere(): void
{
$semaphore = 'EnelixVirtuelleBatterie_' . $this->InstanceID;
if (!IPS_SemaphoreEnter($semaphore, 1000)) {
return;
}
try {
if (!(bool) $this->GetValue('Aktiv')) {
$this->sichererZustand('');
$this->SetStatus(self::STATUS_INAKTIV);
return;
}
$this->pruefeKonfiguration();
[$batterien, $fehler] = $this->leseBatterien();
if ($batterien === []) {
throw new RuntimeException(
'Keine Batterie besitzt aktuelle gueltige Messwerte.'
);
}
$aggregat = VirtuelleBatterieRegler::aggregiere(
$batterien,
(float) $this->ReadPropertyInteger('SDLReserveLaden'),
(float) $this->ReadPropertyInteger('SDLReserveEntladen'),
$this->ReadPropertyFloat('Reservezeit')
);
$this->initialisiereKonten($aggregat);
$jetzt = time();
$timeout = $this->ReadPropertyInteger('VorgabeTimeout');
$ev = $jetzt - $this->ReadAttributeInteger('LetzteEVVorgabe')
<= $timeout
? $this->ReadAttributeFloat('EigenverbrauchAuftrag_W')
: 0.0;
$sdl = $jetzt - $this->ReadAttributeInteger('LetzteSDLVorgabe')
<= $timeout
? (float) $this->GetValue('SDLSollleistung')
: 0.0;
$auftrag = VirtuelleBatterieRegler::kombiniere(
$ev,
$sdl,
$aggregat
);
$verteilung = VirtuelleBatterieRegler::verteile(
$auftrag['Gesamt_W'],
$batterien
);
$this->schreibeVerteilung($verteilung);
$zuordnung = VirtuelleBatterieRegler::ordneIstleistungZu(
$aggregat['Istleistung_W'],
$auftrag['SDL_W']
);
$integrationsfehler = $this->integriereKonten(
$zuordnung,
$aggregat,
$jetzt
);
$this->SetValue(
'GesamtLadezustand',
$aggregat['Ladezustand_Prozent']
);
$this->SetValue(
'GesamtIstleistung',
$aggregat['Istleistung_W']
);
$this->SetValue('GesamtMaxLaden', $aggregat['MaxLaden_W']);
$this->SetValue(
'GesamtMaxEntladen',
$aggregat['MaxEntladen_W']
);
$this->SetValue(
'EigenverbrauchSollleistung',
$auftrag['Eigenverbrauch_W']
);
$this->SetValue(
'EigenverbrauchIstleistung',
$zuordnung['Eigenverbrauch_W']
);
$this->SetValue(
'EigenverbrauchMaxLaden',
$aggregat['EigenverbrauchMaxLaden_W']
);
$this->SetValue(
'EigenverbrauchMaxEntladen',
$aggregat['EigenverbrauchMaxEntladen_W']
);
$this->SetValue('SDLIstleistung', $zuordnung['SDL_W']);
$this->SetValue('SDLMaxLaden', $aggregat['SDLMaxLaden_W']);
$this->SetValue(
'SDLMaxEntladen',
$aggregat['SDLMaxEntladen_W']
);
$this->aktualisiereKontenanzeigen($aggregat);
$status = [
'Eigenverbrauch_W' => round($auftrag['Eigenverbrauch_W'], 1),
'SDL_W' => round($auftrag['SDL_W'], 1),
'Gesamt_W' => round($auftrag['Gesamt_W'], 1),
'Batterien' => count($batterien),
'Verteilung_W' => $verteilung,
];
$this->SetValue(
'Verteilstatus',
json_encode($status, JSON_THROW_ON_ERROR)
);
$fehler = array_values(array_filter(array_merge(
$fehler,
$integrationsfehler === '' ? [] : [$integrationsfehler]
)));
$this->setzeFehler(implode(' ', $fehler));
$this->SetStatus(
$fehler === []
? self::STATUS_AKTIV
: self::STATUS_MESSWERTE_UNGUELTIG
);
$this->protokolliere('Verteilung', $status);
} catch (InvalidArgumentException $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
} catch (Throwable $fehler) {
$this->sichererZustand($fehler->getMessage());
$this->SetStatus(self::STATUS_AUSGABE_FEHLER);
} finally {
IPS_SemaphoreLeave($semaphore);
}
}
/**
* @return array{0:list<array<string, int|float|string>>, 1:list<string>}
*/
private function leseBatterien(): array
{
$konfiguration = $this->leseBatterieliste();
$batterien = [];
$fehler = [];
$maxAlter = $this->ReadPropertyInteger('MesswertMaxAlter');
foreach ($konfiguration as $index => $eintrag) {
$name = trim((string) ($eintrag['Name'] ?? ''));
if ($name === '') {
$name = 'Batterie ' . ($index + 1);
}
$socID = (int) ($eintrag['LadezustandVariableID'] ?? 0);
$leistungID = (int) ($eintrag['IstleistungVariableID'] ?? 0);
if (!$this->istAktuellerMesswert($socID, $maxAlter)
|| !$this->istAktuellerMesswert($leistungID, $maxAlter)
) {
$fehler[] = $name . ': Messwerte fehlen oder sind veraltet.';
continue;
}
$batterien[] = [
'Index' => $index,
'Name' => $name,
'Kapazitaet_kWh' =>
(float) ($eintrag['Kapazitaet_kWh'] ?? 0.0),
'MaxLaden_W' => (float) ($eintrag['MaxLaden_W'] ?? 0.0),
'MaxEntladen_W' =>
(float) ($eintrag['MaxEntladen_W'] ?? 0.0),
'Ladezustand_Prozent' => (float) GetValue($socID),
'Istleistung_W' => (float) GetValue($leistungID),
];
}
return [$batterien, $fehler];
}
/** @return list<array<string, mixed>> */
private function leseBatterieliste(): array
{
$liste = json_decode(
$this->ReadPropertyString('Batterieliste'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($liste)) {
throw new InvalidArgumentException(
'Batterieliste muss eine JSON-Liste sein.'
);
}
return array_values($liste);
}
private function pruefeKonfiguration(): void
{
foreach ([
'Aktualisierungsintervall',
'MesswertMaxAlter',
'VorgabeTimeout',
] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException(
$property . ' muss groesser als 0 sein.'
);
}
}
if ($this->ReadPropertyFloat('Reservezeit') < 0.0) {
throw new InvalidArgumentException(
'Reservezeit darf nicht negativ sein.'
);
}
$liste = $this->leseBatterieliste();
if ($liste === []) {
throw new InvalidArgumentException(
'Mindestens eine physische Batterie ist erforderlich.'
);
}
$zielIDs = [];
foreach ($liste as $index => $eintrag) {
$name = 'Batterie ' . ($index + 1);
if ((float) ($eintrag['Kapazitaet_kWh'] ?? 0.0) <= 0.0) {
throw new InvalidArgumentException(
$name . ': Kapazitaet muss groesser als 0 sein.'
);
}
foreach ([
'LadezustandVariableID',
'IstleistungVariableID',
] as $feld) {
$this->pruefeNumerischeVariable(
(int) ($eintrag[$feld] ?? 0),
$name . ' ' . $feld,
false
);
}
$zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0);
$this->pruefeNumerischeVariable(
$zielID,
$name . ' SollleistungVariableID',
true
);
if (isset($zielIDs[$zielID])) {
throw new InvalidArgumentException(
'Jede Batterie benoetigt eine eigene Sollleistungsvariable.'
);
}
$zielIDs[$zielID] = true;
}
}
private function pruefeNumerischeVariable(
int $id,
string $name,
bool $aktion
): void {
if ($id <= 0 || !IPS_VariableExists($id)) {
throw new InvalidArgumentException($name . ' ist nicht eingerichtet.');
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
throw new InvalidArgumentException($name . ' muss numerisch sein.');
}
if ($aktion
&& (int) ($variable['VariableAction'] ?? 0) <= 0
&& (int) ($variable['VariableCustomAction'] ?? 0) <= 0
) {
throw new InvalidArgumentException(
$name . ' benoetigt eine Variablenaktion.'
);
}
}
private function istAktuellerMesswert(int $id, int $maxAlter): bool
{
if ($id <= 0 || !IPS_VariableExists($id)) {
return false;
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return false;
}
$aktualisiert = (int) ($variable['VariableUpdated'] ?? 0);
return $aktualisiert > 0 && time() - $aktualisiert <= $maxAlter;
}
/** @param array<int, float> $verteilung */
private function schreibeVerteilung(array $verteilung): void
{
$alt = json_decode(
$this->ReadAttributeString('LetzteZielwerte'),
true
);
if (!is_array($alt)) {
$alt = [];
}
$neu = [];
foreach ($this->leseBatterieliste() as $index => $eintrag) {
$zielID = (int) ($eintrag['SollleistungVariableID'] ?? 0);
$ziel = (float) ($verteilung[$index] ?? 0.0);
$neu[(string) $zielID] = $ziel;
if (!isset($alt[(string) $zielID])
|| abs((float) $alt[(string) $zielID] - $ziel) >= 0.5
) {
RequestAction($zielID, $ziel);
}
}
$this->WriteAttributeString(
'LetzteZielwerte',
json_encode($neu, JSON_THROW_ON_ERROR)
);
}
private function sichererZustand(string $fehler): void
{
try {
$this->schreibeVerteilung([]);
} catch (Throwable $ausgabeFehler) {
if ($fehler === '') {
$fehler = $ausgabeFehler->getMessage();
} else {
$fehler .= ' ' . $ausgabeFehler->getMessage();
}
}
$this->SetValue('EigenverbrauchSollleistung', 0.0);
$this->SetValue('EigenverbrauchIstleistung', 0.0);
$this->SetValue('SDLIstleistung', 0.0);
$this->setzeFehler($fehler);
}
/** @param array<string, float> $aggregat */
private function initialisiereKonten(array $aggregat): void
{
if ($this->ReadAttributeBoolean('KontenInitialisiert')) {
return;
}
$evProzent = min(
100.0,
max(0.0, $this->ReadPropertyFloat('EigenverbrauchStart'))
);
$sdlProzent = min(
100.0,
max(0.0, $this->ReadPropertyFloat('SDLStart'))
);
$this->WriteAttributeFloat(
'EigenverbrauchEnergie_kWh',
$aggregat['EigenverbrauchKapazitaet_kWh'] * $evProzent / 100.0
);
$this->WriteAttributeFloat(
'SDLEnergie_kWh',
$aggregat['SDLKapazitaet_kWh'] * $sdlProzent / 100.0
);
$this->WriteAttributeInteger('LetzteIntegration', time());
$this->WriteAttributeBoolean('KontenInitialisiert', true);
}
/**
* @param array{Eigenverbrauch_W:float, SDL_W:float} $zuordnung
* @param array<string, float> $aggregat
*/
private function integriereKonten(
array $zuordnung,
array $aggregat,
int $jetzt
): string {
$letzteZeit = $this->ReadAttributeInteger('LetzteIntegration');
$this->WriteAttributeInteger('LetzteIntegration', $jetzt);
if ($letzteZeit <= 0 || $jetzt <= $letzteZeit) {
return '';
}
$dauer = $jetzt - $letzteZeit;
$maxLuecke = max(
$this->ReadPropertyInteger('MesswertMaxAlter'),
$this->ReadPropertyInteger('Aktualisierungsintervall') * 3
);
if ($dauer > $maxLuecke) {
return 'Integrationsluecke erkannt; Konten wurden nicht fortgeschrieben.';
}
$evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh'];
$sdlKapazitaet = $aggregat['SDLKapazitaet_kWh'];
$evEnergie = $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh')
+ $zuordnung['Eigenverbrauch_W'] * $dauer / 3600000.0;
$sdlEnergie = $this->ReadAttributeFloat('SDLEnergie_kWh')
+ $zuordnung['SDL_W'] * $dauer / 3600000.0;
$this->WriteAttributeFloat(
'EigenverbrauchEnergie_kWh',
min($evKapazitaet, max(0.0, $evEnergie))
);
$this->WriteAttributeFloat(
'SDLEnergie_kWh',
min($sdlKapazitaet, max(0.0, $sdlEnergie))
);
return '';
}
/** @param array<string, float> $aggregat */
private function aktualisiereKontenanzeigen(array $aggregat): void
{
$evKapazitaet = $aggregat['EigenverbrauchKapazitaet_kWh'];
$sdlKapazitaet = $aggregat['SDLKapazitaet_kWh'];
$this->SetValue(
'EigenverbrauchLadezustand',
$evKapazitaet > 0.0
? $this->ReadAttributeFloat('EigenverbrauchEnergie_kWh')
/ $evKapazitaet * 100.0
: 0.0
);
$this->SetValue(
'SDLLadezustand',
$sdlKapazitaet > 0.0
? $this->ReadAttributeFloat('SDLEnergie_kWh')
/ $sdlKapazitaet * 100.0
: 0.0
);
}
private function registriereMesswertmeldungen(): void
{
$alteIDs = json_decode(
$this->ReadAttributeString('RegistrierteMesswerte'),
true
);
if (!is_array($alteIDs)) {
$alteIDs = [];
}
$neueIDs = [];
try {
foreach ($this->leseBatterieliste() as $eintrag) {
foreach ([
'LadezustandVariableID',
'IstleistungVariableID',
] as $feld) {
$id = (int) ($eintrag[$feld] ?? 0);
if ($id > 0 && IPS_VariableExists($id)) {
$neueIDs[] = $id;
}
}
}
} catch (Throwable $fehler) {
$neueIDs = [];
}
$neueIDs = array_values(array_unique($neueIDs));
foreach ($alteIDs as $id) {
if (!in_array((int) $id, $neueIDs, true)) {
$this->UnregisterMessage((int) $id, self::VM_UPDATE);
}
}
foreach ($neueIDs as $id) {
if (!in_array($id, $alteIDs, true)) {
$this->RegisterMessage($id, self::VM_UPDATE);
}
}
$this->WriteAttributeString(
'RegistrierteMesswerte',
json_encode($neueIDs, JSON_THROW_ON_ERROR)
);
}
private function setzeFehler(string $text): void
{
$this->SetValue('Stoerung', $text !== '');
$this->SetValue('Stoertext', $text);
}
/** @param mixed $daten */
private function protokolliere(string $titel, $daten): void
{
if (!$this->ReadPropertyBoolean('LoggingEin')) {
return;
}
$text = is_string($daten)
? $daten
: json_encode($daten, JSON_THROW_ON_ERROR);
$this->SendDebug($titel, $text, 0);
}
}