feat(utils): adapt virtual battery and VGT interface
Tests / test (push) Successful in 46s

This commit is contained in:
dh
2026-09-27 12:28:32 +00:00
parent 92eec804a7
commit f11b1fbe61
22 changed files with 2445 additions and 104 deletions
+7 -5
View File
@@ -4,17 +4,17 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
## Status ## Status
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, CC100 Hardware, Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Virtuelle
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module Batterie, CC100 Hardware, Energiediagramm, VGT-Schnittstelle und Shelly Modul
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert. sind als installierbare IP-Symcon-Module enthalten.
## Module ## Module
- Verbrauchskostenreport (implementiert) - Verbrauchskostenreport (implementiert)
- Virtuelle Batterie - Virtuelle Batterie (implementiert)
- CC100 Hardware (implementiert) - CC100 Hardware (implementiert)
- Energiediagramm (implementiert) - Energiediagramm (implementiert)
- VGT-Schnittstelle - VGT-Schnittstelle (implementiert)
- Shelly Modul (implementiert) - Shelly Modul (implementiert)
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
@@ -62,7 +62,9 @@ Module geladen sein:
- `CC100 Hardware` - `CC100 Hardware`
- `Energiediagramm` - `Energiediagramm`
- `Shelly Modul` - `Shelly Modul`
- `VGT-Schnittstelle`
- `Verbrauchskostenreport` - `Verbrauchskostenreport`
- `Virtuelle Batterie`
Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und
kein Warnsymbol bei `Enelix Utils`. kein Warnsymbol bei `Enelix Utils`.
+9
View File
@@ -0,0 +1,9 @@
# VGT-Schnittstelle
Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0.
Das Modul fuehrt die bisherigen PV- und Batterie-SDL-Module zusammen. Die
bestehenden MQTT-Topics und Payload-Felder bleiben unveraendert.
Die vollstaendige Konfiguration steht in
[`docs/module/VGT-Schnittstelle/README.md`](../docs/module/VGT-Schnittstelle/README.md).
+78
View File
@@ -0,0 +1,78 @@
{
"elements": [
{
"type": "Label",
"caption": "Die MQTT-Topics und Nutzdaten entsprechen unveraendert den bisherigen MQTTPVSDL- und MQTTBatterySDL-Modulen."
},
{
"type": "Select",
"name": "Geraeteart",
"caption": "Geraeteart",
"options": [
{"caption": "PV", "value": 0},
{"caption": "Batterie / SDL-Anteil", "value": 1}
]
},
{
"type": "ValidationTextBox",
"name": "TopicSuffix",
"caption": "Topic Suffix"
},
{
"type": "SelectVariable",
"name": "ReqActionID",
"caption": "Ausgabe-Variable / SDL-Sollleistung"
},
{
"type": "SelectVariable",
"name": "PowerProductionID",
"caption": "Aktuelle Leistung SDL"
},
{
"type": "SelectVariable",
"name": "SoCID",
"caption": "SDL-Ladezustand (nur Batterie)"
},
{
"type": "NumberSpinner",
"name": "TargetSoC",
"caption": "Ziel-Ladezustand",
"suffix": " %",
"minimum": 0,
"maximum": 100
},
{
"type": "NumberSpinner",
"name": "ChargePower",
"caption": "Ladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "DischargePower",
"caption": "Entladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "MaxPowerSetpoint",
"caption": "Maximaler Power Setpoint",
"suffix": " W",
"minimum": 0
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Debug-Logging aktivieren"
}
],
"actions": [
{
"type": "Button",
"caption": "Steuerung testen",
"onClick": "ENLVGT_RunControl($id);"
}
]
}
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils;
use InvalidArgumentException;
final class VGTRegler
{
public const GERAETEART_PV = 0;
public const GERAETEART_BATTERIE = 1;
public static function begrenzeSetpoint(
int $geraeteart,
float $wert,
float $maximum
): float {
self::pruefeGeraeteart($geraeteart);
if ($maximum < 0.0) {
throw new InvalidArgumentException(
'MaxPowerSetpoint darf nicht negativ sein.'
);
}
if ($geraeteart === self::GERAETEART_PV) {
return min($maximum, max(0.0, $wert));
}
return min($maximum, max(-$maximum, $wert));
}
public static function zielwert(
int $geraeteart,
string $strategie,
float $setpoint,
float $maximum,
float $ladezustand,
float $zielLadezustand,
float $ladeleistung,
float $entladeleistung,
float $minLadezustand,
float $maxLadezustand
): float {
self::pruefeGeraeteart($geraeteart);
$strategie = trim($strategie);
$setpoint = self::begrenzeSetpoint(
$geraeteart,
$setpoint,
$maximum
);
if ($strategie === 'stop') {
return $geraeteart === self::GERAETEART_PV ? $maximum : 0.0;
}
if ($strategie === 'activate') {
$ziel = $geraeteart === self::GERAETEART_PV
? $setpoint
: -$setpoint;
return self::begrenzeNachLadezustand(
$geraeteart,
$ziel,
$ladezustand,
$minLadezustand,
$maxLadezustand
);
}
if ($geraeteart === self::GERAETEART_PV) {
return $maximum;
}
if ($ladezustand >= $zielLadezustand
&& $ladezustand <= $zielLadezustand + 1.0
) {
return 0.0;
}
$ziel = $ladezustand < $zielLadezustand
? abs($ladeleistung)
: -abs($entladeleistung);
$ziel = self::begrenzeSetpoint($geraeteart, $ziel, $maximum);
return self::begrenzeNachLadezustand(
$geraeteart,
$ziel,
$ladezustand,
$minLadezustand,
$maxLadezustand
);
}
private static function begrenzeNachLadezustand(
int $geraeteart,
float $zielW,
float $ladezustand,
float $minimum,
float $maximum
): float {
if ($geraeteart === self::GERAETEART_PV) {
return $zielW;
}
if ($minimum < 0.0
|| $maximum > 100.0
|| $minimum > $maximum
) {
throw new InvalidArgumentException(
'MinSoC und MaxSoC sind ungueltig.'
);
}
if (($zielW < 0.0 && $ladezustand <= $minimum)
|| ($zielW > 0.0 && $ladezustand >= $maximum)
) {
return 0.0;
}
return $zielW;
}
private static function pruefeGeraeteart(int $geraeteart): void
{
if (!in_array($geraeteart, [
self::GERAETEART_PV,
self::GERAETEART_BATTERIE,
], true)) {
throw new InvalidArgumentException('Geraeteart ist ungueltig.');
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"id": "{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}",
"name": "VGTSchnittstelle",
"type": 3,
"vendor": "Enelix",
"aliases": [
"VGT-Schnittstelle",
"MQTT PV SDL",
"MQTT Battery SDL"
],
"parentRequirements": [
"{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}"
],
"childRequirements": [],
"implemented": [
"{7F7632D9-FA40-4F38-8DEA-C83CD4325A32}"
],
"prefix": "ENLVGT",
"url": ""
}
+432
View File
@@ -0,0 +1,432 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/libs/VGTRegler.php';
use Belevo\EnelixUtils\VGTRegler;
class VGTSchnittstelle extends IPSModule
{
private const STATUS_AKTIV = 102;
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
private const STATUS_AUSGABE_FEHLER = 202;
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger(
'Geraeteart',
VGTRegler::GERAETEART_PV
);
$this->RegisterPropertyString('TopicSuffix', '');
$this->RegisterPropertyInteger('ReqActionID', 0);
$this->RegisterPropertyInteger('PowerProductionID', 0);
$this->RegisterPropertyInteger('SoCID', 0);
$this->RegisterPropertyFloat('TargetSoC', 50.0);
$this->RegisterPropertyInteger('ChargePower', 2500);
$this->RegisterPropertyInteger('DischargePower', 2500);
$this->RegisterPropertyInteger('MaxPowerSetpoint', 10000);
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('IsReady', 'Is Ready', '', 10);
$this->RegisterVariableBoolean('IsRunning', 'Is Running', '', 20);
$this->RegisterVariableFloat('MinSoC', 'Min SoC', '', 30);
$this->EnableAction('MinSoC');
$this->RegisterVariableFloat('MaxSoC', 'Max SoC', '', 40);
$this->EnableAction('MaxSoC');
$this->RegisterVariableInteger(
'PowerSetpoint',
'Power Setpoint',
'',
50
);
$this->EnableAction('PowerSetpoint');
$this->RegisterVariableString('Strategy', 'Strategy', '', 60);
$this->EnableAction('Strategy');
$this->RegisterVariableString(
'LastReadResponse',
'Letzte Lese-Antwort',
'',
70
);
$this->RegisterVariableString(
'LastWriteResponse',
'Letzte Steuer-Antwort',
'',
80
);
$this->RegisterVariableBoolean('Stoerung', 'Stoerung', '~Switch', 90);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 100);
$this->RegisterAttributeString('PublishQueue', '[]');
$this->RegisterAttributeFloat('LetzterZielwert', 0.0);
$this->RegisterAttributeBoolean('SoCGrenzenInitialisiert', false);
$this->ConnectParent('{F7A0DD2E-7684-95C0-64C2-D2A9DC47577B}');
$this->RegisterTimer(
'PublishDelay',
0,
'ENLVGT_DoDelayedPublish($_IPS["TARGET"]);'
);
$this->RegisterTimer(
'BatteryControlTimer',
0,
'ENLVGT_RunControl($_IPS["TARGET"]);'
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
try {
if (!$this->ReadAttributeBoolean('SoCGrenzenInitialisiert')) {
$this->SetValue('MinSoC', 0.0);
$this->SetValue('MaxSoC', 100.0);
$this->WriteAttributeBoolean('SoCGrenzenInitialisiert', true);
}
$this->pruefeKonfiguration();
$suffix = $this->ReadPropertyString('TopicSuffix');
$filter = '.*"Topic":"(feedback-request|remote-control-request)\\/'
. preg_quote($suffix, '/')
. '".*';
$this->SetReceiveDataFilter($filter);
$this->SetTimerInterval('BatteryControlTimer', 3000);
$this->SetValue('IsReady', true);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
$this->RunControl();
} catch (Throwable $fehler) {
$this->SetTimerInterval('BatteryControlTimer', 0);
$this->SetValue('IsReady', false);
$this->SetValue('IsRunning', false);
$this->setzeFehler($fehler->getMessage());
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'PowerSetpoint':
$begrenzt = VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
(float) $wert,
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
$this->SetValue('PowerSetpoint', (int) round($begrenzt));
$this->RunControl();
return;
case 'Strategy':
$this->SetValue('Strategy', trim((string) $wert));
$this->RunControl();
return;
case 'MinSoC':
$this->SetValue(
'MinSoC',
min(100.0, max(0.0, (float) $wert))
);
$this->RunControl();
return;
case 'MaxSoC':
$this->SetValue(
'MaxSoC',
min(100.0, max(0.0, (float) $wert))
);
$this->RunControl();
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
public function RunControl(): void
{
try {
$this->pruefeKonfiguration();
$geraeteart = $this->ReadPropertyInteger('Geraeteart');
$strategie = (string) $this->GetValue('Strategy');
if ($geraeteart === VGTRegler::GERAETEART_PV
&& !in_array($strategie, ['activate', 'stop'], true)
) {
$this->SetValue('IsReady', true);
$this->SetValue('IsRunning', false);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
return;
}
$ladezustand = $geraeteart === VGTRegler::GERAETEART_BATTERIE
? $this->leseNumerisch($this->ReadPropertyInteger('SoCID'))
: 0.0;
$zielwert = VGTRegler::zielwert(
$geraeteart,
$strategie,
(float) $this->GetValue('PowerSetpoint'),
(float) $this->ReadPropertyInteger('MaxPowerSetpoint'),
$ladezustand,
$this->ReadPropertyFloat('TargetSoC'),
(float) $this->ReadPropertyInteger('ChargePower'),
(float) $this->ReadPropertyInteger('DischargePower'),
(float) $this->GetValue('MinSoC'),
(float) $this->GetValue('MaxSoC')
);
RequestAction(
$this->ReadPropertyInteger('ReqActionID'),
$zielwert
);
$this->WriteAttributeFloat('LetzterZielwert', $zielwert);
$this->SetValue('IsReady', true);
$this->SetValue('IsRunning', abs($zielwert) >= 0.5);
$this->setzeFehler('');
$this->SetStatus(self::STATUS_AKTIV);
$this->protokolliere('Zielwert', $zielwert);
} catch (Throwable $fehler) {
$this->SetValue('IsRunning', false);
$this->setzeFehler($fehler->getMessage());
$this->SetStatus(self::STATUS_AUSGABE_FEHLER);
$this->protokolliere('Steuerfehler', $fehler->getMessage());
}
}
public function BuildReadResponse(): string
{
$data = [
'power_production' => $this->leseOptionalNumerisch(
$this->ReadPropertyInteger('PowerProductionID')
),
'is_ready' => (bool) $this->GetValue('IsReady'),
'is_running' => (bool) $this->GetValue('IsRunning'),
];
if ($this->ReadPropertyInteger('Geraeteart')
=== VGTRegler::GERAETEART_BATTERIE
) {
$data['state_of_charge'] = $this->leseOptionalNumerisch(
$this->ReadPropertyInteger('SoCID')
);
$data['min_soc'] = (float) $this->GetValue('MinSoC');
$data['max_soc'] = (float) $this->GetValue('MaxSoC');
}
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
$this->SetValue('LastReadResponse', $json);
return $json;
}
public function HandleRemoteControlJSON(string $payload): ?string
{
if ($payload === '') {
return null;
}
$data = json_decode($payload, true);
if (!is_array($data)) {
return null;
}
if (array_key_exists('power_setpoint', $data)) {
$wert = VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
(float) $data['power_setpoint'],
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
$this->SetValue('PowerSetpoint', (int) round($wert));
}
if (array_key_exists('strategy', $data)) {
$this->SetValue('Strategy', (string) $data['strategy']);
}
$this->RunControl();
$antwort = [
'power_setpoint' => (int) $this->GetValue('PowerSetpoint'),
'strategy' => (string) $this->GetValue('Strategy'),
];
$json = json_encode(
$antwort,
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR
);
$this->SetValue('LastWriteResponse', $json);
return $json;
}
public function ReceiveData($jsonString): void
{
$data = json_decode((string) $jsonString, true);
if (!is_array($data)
|| (int) ($data['PacketType'] ?? 0) !== 3
|| !isset($data['Topic'])
) {
return;
}
$topic = (string) $data['Topic'];
$payload = (string) ($data['Payload'] ?? '');
$suffix = $this->ReadPropertyString('TopicSuffix');
$this->protokolliere('ReceiveData', $jsonString);
if ($topic === 'feedback-request/' . $suffix) {
$this->QueuePublish(
'feedback-response/' . $suffix,
$this->BuildReadResponse()
);
return;
}
if ($topic === 'remote-control-request/' . $suffix) {
$antwort = $this->HandleRemoteControlJSON($payload);
if ($antwort !== null) {
$this->QueuePublish(
'remote-control-response/' . $suffix,
$antwort
);
}
}
}
public function QueuePublish(string $topic, string $payload): void
{
$queue = json_decode(
$this->ReadAttributeString('PublishQueue'),
true
);
if (!is_array($queue)) {
$queue = [];
}
$queue[] = ['Topic' => $topic, 'Payload' => $payload];
if (count($queue) > 100) {
array_shift($queue);
}
$this->WriteAttributeString(
'PublishQueue',
json_encode($queue, JSON_THROW_ON_ERROR)
);
$this->SetTimerInterval('PublishDelay', 50);
}
public function DoDelayedPublish(): void
{
$queue = json_decode(
$this->ReadAttributeString('PublishQueue'),
true
);
if (!is_array($queue) || $queue === []) {
$this->SetTimerInterval('PublishDelay', 0);
return;
}
$eintrag = array_shift($queue);
$this->WriteAttributeString(
'PublishQueue',
json_encode($queue, JSON_THROW_ON_ERROR)
);
$this->SendDataToParent(json_encode([
'DataID' => '{043EA491-0325-4ADD-8FC2-A30C8EEB4D3F}',
'PacketType' => 3,
'QualityOfService' => 0,
'Retain' => false,
'Topic' => (string) $eintrag['Topic'],
'Payload' => (string) $eintrag['Payload'],
], JSON_THROW_ON_ERROR));
$this->SetTimerInterval('PublishDelay', $queue === [] ? 0 : 50);
}
private function pruefeKonfiguration(): void
{
VGTRegler::begrenzeSetpoint(
$this->ReadPropertyInteger('Geraeteart'),
0.0,
(float) $this->ReadPropertyInteger('MaxPowerSetpoint')
);
if (trim($this->ReadPropertyString('TopicSuffix')) === '') {
throw new InvalidArgumentException(
'TopicSuffix ist nicht eingerichtet.'
);
}
$this->pruefeAktionsvariable(
$this->ReadPropertyInteger('ReqActionID'),
'ReqActionID'
);
if ($this->ReadPropertyInteger('Geraeteart')
=== VGTRegler::GERAETEART_BATTERIE
) {
$this->pruefeNumerischeVariable(
$this->ReadPropertyInteger('SoCID'),
'SoCID'
);
}
}
private function pruefeAktionsvariable(int $id, string $name): void
{
$this->pruefeNumerischeVariable($id, $name);
$variable = IPS_GetVariable($id);
if ((int) ($variable['VariableAction'] ?? 0) <= 0
&& (int) ($variable['VariableCustomAction'] ?? 0) <= 0
) {
throw new InvalidArgumentException(
$name . ' benoetigt eine Variablenaktion.'
);
}
}
private function pruefeNumerischeVariable(int $id, string $name): 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.'
);
}
}
private function leseNumerisch(int $id): float
{
$this->pruefeNumerischeVariable($id, 'Messvariable');
return (float) GetValue($id);
}
private function leseOptionalNumerisch(int $id): float
{
if ($id <= 0 || !IPS_VariableExists($id)) {
return 0.0;
}
$variable = IPS_GetVariable($id);
if (!in_array((int) $variable['VariableType'], [1, 2], true)) {
return 0.0;
}
return (float) GetValue($id);
}
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);
}
}
+10
View File
@@ -0,0 +1,10 @@
# Virtuelle Batterie
Installierbares IP-Symcon-Modul fuer Enelix Utils ab Version 8.0.
Es aggregiert mehrere physische Batterien, reserviert einen priorisierten
SDL-Anteil und stellt dem vorhandenen Enelix-EMS-Batteriemodul generische
Proxyregister fuer den Eigenverbrauch bereit.
Konfiguration, Verdrahtung und Sicherheitsverhalten stehen in
[`docs/module/Virtuelle-Batterie/README.md`](../docs/module/Virtuelle-Batterie/README.md).
+167
View File
@@ -0,0 +1,167 @@
{
"elements": [
{
"type": "Label",
"caption": "Fasst mehrere physische Batterien zu einer virtuellen Batterie zusammen. SDL hat bei der Leistungsverteilung Vorrang."
},
{
"type": "List",
"name": "Batterieliste",
"caption": "Physische Batterien",
"rowCount": 5,
"add": true,
"delete": true,
"columns": [
{
"caption": "Name",
"name": "Name",
"width": "150px",
"add": "",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Kapazitaet",
"name": "Kapazitaet_kWh",
"width": "110px",
"suffix": " kWh",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 2}
},
{
"caption": "Max. Laden",
"name": "MaxLaden_W",
"width": "110px",
"suffix": " W",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0}
},
{
"caption": "Max. Entladen",
"name": "MaxEntladen_W",
"width": "120px",
"suffix": " W",
"add": 0,
"edit": {"type": "NumberSpinner", "minimum": 0, "digits": 0}
},
{
"caption": "Ladezustand",
"name": "LadezustandVariableID",
"width": "130px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Istleistung",
"name": "IstleistungVariableID",
"width": "130px",
"add": 0,
"edit": {"type": "SelectVariable"}
},
{
"caption": "Sollleistung",
"name": "SollleistungVariableID",
"width": "140px",
"add": 0,
"edit": {"type": "SelectVariable"}
}
],
"values": []
},
{
"type": "ExpansionPanel",
"caption": "SDL-Reserve",
"items": [
{
"type": "NumberSpinner",
"name": "SDLReserveLaden",
"caption": "Reservierte SDL-Ladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "SDLReserveEntladen",
"caption": "Reservierte SDL-Entladeleistung",
"suffix": " W",
"minimum": 0
},
{
"type": "NumberSpinner",
"name": "Reservezeit",
"caption": "SDL-Reservezeit",
"suffix": " h",
"minimum": 0,
"maximum": 24,
"digits": 2
}
]
},
{
"type": "ExpansionPanel",
"caption": "Regelung und Sicherheit",
"items": [
{
"type": "NumberSpinner",
"name": "Aktualisierungsintervall",
"caption": "Aktualisierungsintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VorgabeTimeout",
"caption": "Timeout fuer EV- und SDL-Vorgaben",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "EigenverbrauchStart",
"caption": "Startwert Eigenverbrauchskonto",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "NumberSpinner",
"name": "SDLStart",
"caption": "Startwert SDL-Konto",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "CheckBox",
"name": "DiagnoseAnzeigen",
"caption": "Diagnosevariablen anzeigen"
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Debug-Logging aktivieren"
}
]
}
],
"actions": [
{
"type": "Button",
"caption": "Virtuelle Konten auf Startwerte zuruecksetzen",
"onClick": "IPS_RequestAction($id, 'KontenReset', true);"
},
{
"type": "Button",
"caption": "Jetzt aktualisieren",
"onClick": "IPS_RequestAction($id, 'Aktualisieren', 0);"
}
]
}
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils;
use InvalidArgumentException;
final class VirtuelleBatterieRegler
{
/**
* @param list<array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }> $batterien
* @return array<string, float>
*/
public static function aggregiere(
array $batterien,
float $sdlReserveLadenW,
float $sdlReserveEntladenW,
float $reservezeitH
): array {
if ($sdlReserveLadenW < 0.0
|| $sdlReserveEntladenW < 0.0
|| $reservezeitH < 0.0
) {
throw new InvalidArgumentException(
'SDL-Reserven und Reservezeit duerfen nicht negativ sein.'
);
}
$kapazitaet = 0.0;
$energie = 0.0;
$maxLaden = 0.0;
$maxEntladen = 0.0;
$istleistung = 0.0;
foreach ($batterien as $batterie) {
$kapazitaetKWh = (float) $batterie['Kapazitaet_kWh'];
$ladezustand = (float) $batterie['Ladezustand_Prozent'];
$ladenW = (float) $batterie['MaxLaden_W'];
$entladenW = (float) $batterie['MaxEntladen_W'];
if ($kapazitaetKWh <= 0.0 || $ladenW < 0.0 || $entladenW < 0.0) {
throw new InvalidArgumentException(
'Kapazitaet muss positiv und Leistungsgrenzen duerfen nicht negativ sein.'
);
}
if ($ladezustand < 0.0 || $ladezustand > 100.0) {
throw new InvalidArgumentException(
'Ladezustand muss zwischen 0 und 100 Prozent liegen.'
);
}
$kapazitaet += $kapazitaetKWh;
$energie += $kapazitaetKWh * $ladezustand / 100.0;
$maxLaden += $ladenW;
$maxEntladen += $entladenW;
$istleistung += (float) $batterie['Istleistung_W'];
}
if ($kapazitaet <= 0.0) {
throw new InvalidArgumentException(
'Mindestens eine verfuegbare Batterie ist erforderlich.'
);
}
$sdlLadenW = min($sdlReserveLadenW, $maxLaden);
$sdlEntladenW = min($sdlReserveEntladenW, $maxEntladen);
$untereReserve = $sdlEntladenW * $reservezeitH / 1000.0;
$obereReserve = $sdlLadenW * $reservezeitH / 1000.0;
if ($untereReserve + $obereReserve > $kapazitaet) {
throw new InvalidArgumentException(
'Die SDL-Energiereserven sind groesser als die Gesamtkapazitaet.'
);
}
return [
'Kapazitaet_kWh' => $kapazitaet,
'Energie_kWh' => $energie,
'Ladezustand_Prozent' => $energie / $kapazitaet * 100.0,
'Istleistung_W' => $istleistung,
'MaxLaden_W' => $maxLaden,
'MaxEntladen_W' => $maxEntladen,
'SDLKapazitaet_kWh' => $untereReserve + $obereReserve,
'EigenverbrauchKapazitaet_kWh' =>
$kapazitaet - $untereReserve - $obereReserve,
'SDLMaxLaden_W' => $sdlLadenW,
'SDLMaxEntladen_W' => $sdlEntladenW,
'EigenverbrauchMaxLaden_W' => max(0.0, $maxLaden - $sdlLadenW),
'EigenverbrauchMaxEntladen_W' => max(0.0, $maxEntladen - $sdlEntladenW),
'UntereSDLReserve_kWh' => $untereReserve,
'ObereSDLReserve_kWh' => $obereReserve,
];
}
/**
* SDL wird zuerst begrenzt. Eigenverbrauch erhaelt die verbleibende
* physische Leistung derselben Richtung.
*
* @param array<string, float> $aggregat
* @return array{Eigenverbrauch_W:float, SDL_W:float, Gesamt_W:float}
*/
public static function kombiniere(
float $eigenverbrauchW,
float $sdlW,
array $aggregat
): array {
$sdl = self::begrenze(
$sdlW,
(float) $aggregat['SDLMaxLaden_W'],
(float) $aggregat['SDLMaxEntladen_W']
);
$ev = self::begrenze(
$eigenverbrauchW,
(float) $aggregat['EigenverbrauchMaxLaden_W'],
(float) $aggregat['EigenverbrauchMaxEntladen_W']
);
if ($sdl > 0.0 && $ev > 0.0) {
$ev = min($ev, max(0.0, (float) $aggregat['MaxLaden_W'] - $sdl));
} elseif ($sdl < 0.0 && $ev < 0.0) {
$ev = max($ev, -max(
0.0,
(float) $aggregat['MaxEntladen_W'] - abs($sdl)
));
}
$gesamt = self::begrenze(
$sdl + $ev,
(float) $aggregat['MaxLaden_W'],
(float) $aggregat['MaxEntladen_W']
);
return [
'Eigenverbrauch_W' => $ev,
'SDL_W' => $sdl,
'Gesamt_W' => $gesamt,
];
}
/**
* @param list<array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }> $batterien
* @return array<int, float>
*/
public static function verteile(float $gesamtW, array $batterien): array
{
$ergebnis = [];
foreach ($batterien as $batterie) {
$ergebnis[(int) $batterie['Index']] = 0.0;
}
if (abs($gesamtW) < 0.5) {
return $ergebnis;
}
$laden = $gesamtW > 0.0;
usort(
$batterien,
static function (array $a, array $b) use ($laden): int {
$vergleich = (float) $a['Ladezustand_Prozent']
<=> (float) $b['Ladezustand_Prozent'];
return $laden ? $vergleich : -$vergleich;
}
);
$rest = abs($gesamtW);
foreach ($batterien as $batterie) {
if ($rest < 0.5) {
break;
}
$grenze = $laden
? (float) $batterie['MaxLaden_W']
: (float) $batterie['MaxEntladen_W'];
$anteil = min($rest, max(0.0, $grenze));
$ergebnis[(int) $batterie['Index']] = $laden ? $anteil : -$anteil;
$rest -= $anteil;
}
return $ergebnis;
}
/**
* Die SDL-Leistung wird bilanziell zuerst bedient. Die Differenz zur
* gemessenen Gesamtleistung wird dem Eigenverbrauch zugeordnet.
*
* @return array{Eigenverbrauch_W:float, SDL_W:float}
*/
public static function ordneIstleistungZu(
float $gesamtIstW,
float $sdlAuftragW
): array {
return [
'Eigenverbrauch_W' => $gesamtIstW - $sdlAuftragW,
'SDL_W' => $sdlAuftragW,
];
}
private static function begrenze(
float $leistungW,
float $maxLadenW,
float $maxEntladenW
): float {
return min($maxLadenW, max(-$maxEntladenW, $leistungW));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "{5B2A199B-2226-411F-B83B-FC1CBA58496B}",
"name": "Virtuelle Batterie",
"type": 3,
"vendor": "Enelix",
"aliases": [],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENLVB",
"url": ""
}
+781
View File
@@ -0,0 +1,781 @@
<?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);
}
}
+6 -7
View File
@@ -1,20 +1,19 @@
# Modulübersicht Enelix Utils # Modulübersicht Enelix Utils
> Status: Verbrauchskostenreport, CC100 Hardware, Energiediagramm und Shelly > Status: Alle dokumentierten Enelix-Utils-Module sind implementiert.
> Modul sind implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
| Modul | Herkunft | EMS-Abhängigkeit | | Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- | | --- | --- | --- |
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit | | [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 (implementiert) | Optionaler Vertrag, keine Code-Abhängigkeit |
| [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine | | [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW (implementiert) | Keine |
| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine | | [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine |
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar | | [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL (implementiert) | Optional konfigurierbar |
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und
Störüberwachung sind keine Module dieses Repositories. Störüberwachung sind keine Module dieses Repositories.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Erst nach der `0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Jedes Modul
Teamfreigabe erhält ein Modul `module.json`, `form.json`, `module.php` und besitzt `module.json`, `form.json`, `module.php`, Unit-Tests und einen
zugehörige Unit-Tests. registrierten IP-Symcon-Laufzeittest.
+92 -31
View File
@@ -1,42 +1,103 @@
# VGT-Schnittstelle # VGT-Schnittstelle
> Status: Diskussionsentwurf. Führt `MQTTPVSDL` und die vorhandenen > Status: Implementiert fuer IP-Symcon 8.0. Fuehrt `MQTTPVSDL` und
> Batterie-Felder aus `MQTTBatterySDL` zusammen. > `MQTTBatterySDL` zusammen.
Die bestehende MQTT-Arbeitsweise und technische Alt-Idents bleiben erhalten. Die bestehende MQTT-Schnittstelle wurde unveraendert uebernommen. Weder
Die Geräteart PV oder Batterie steuert nur die sichtbaren Felder. Topic-Namen noch Request- und Response-Felder wurden erweitert oder
umbenannt. Die Geraeteart bestimmt nur Regelverhalten und sichtbare Messwerte.
## Variablen ## Feste MQTT-Schnittstelle
| Technischer Ident | Typ / Zugriff | Beschreibung | | Richtung | Topic |
| --- | --- |
| Lesen | `feedback-request/{TopicSuffix}` |
| Leseantwort | `feedback-response/{TopicSuffix}` |
| Steuern | `remote-control-request/{TopicSuffix}` |
| Steuerantwort | `remote-control-response/{TopicSuffix}` |
Ein Steuerauftrag verwendet weiterhin:
```json
{
"power_setpoint": 3500,
"strategy": "activate"
}
```
Die Steuerantwort enthaelt weiterhin ausschliesslich
`power_setpoint` und `strategy`.
Die Leseantwort enthaelt fuer PV:
- `power_production`
- `is_ready`
- `is_running`
Bei der Geraeteart Batterie kommen unveraendert hinzu:
- `state_of_charge`
- `min_soc`
- `max_soc`
MQTT wird weiterhin mit Pakettyp 3, QoS 0 und `Retain=false` ueber den
IP-Symcon-MQTT-Parent verwendet.
## Batterie und virtuelle Batterie
Im Batteriemodus wird `ReqActionID` auf die Variable `SDLSollleistung`
der virtuellen Batterie gelegt. `PowerProductionID` verweist auf
`SDLIstleistung`, `SoCID` auf `SDLLadezustand`.
Die VGT-Schnittstelle schreibt niemals physische Batterieregister. Die
virtuelle Batterie priorisiert den SDL-Auftrag und verteilt den gemeinsamen
Nettosollwert.
Die Vorzeichenumkehr von Enelix 1 bleibt erhalten: Bei `activate` wird der
empfangene Batterie-`power_setpoint` mit umgekehrtem Vorzeichen an die
Zielvariable uebergeben.
## Strategien
| Strategie | PV | Batterie |
| --- | --- | --- | | --- | --- | --- |
| `IsReady` | Boolean / Anzeige | Bestehender Bereitschaftsstatus. | | `activate` | Setpoint zwischen 0 und Maximum | invertierter Setpoint |
| `IsRunning` | Boolean / Anzeige | Bestehender Bearbeitungsstatus. | | `stop` | Freigabe auf konfiguriertes Maximum | 0 W |
| `MinSoC` | Float / Anzeige | Nur Batterie; untere Ladezustandsgrenze. | | `restore` | keine aktive Fernbegrenzung | Regelung auf `TargetSoC` |
| `MaxSoC` | Float / Anzeige | Nur Batterie; obere Ladezustandsgrenze. |
| `PowerSetpoint` | Integer / bedienbar | Bestehende Leistungsvorgabe und Testaktion. | Im Batteriemodus verhindern `MinSoC` und `MaxSoC` eine Vorgabe in die
| `Strategy` | String / bedienbar | Bestehende Strategie und Testaktion. | falsche Richtung an der jeweiligen Ladezustandsgrenze.
| `LastReadResponse` | String / Anzeige | Letzte Lese-Antwort. |
| `LastWriteResponse` | String / Anzeige | Letzte Steuer-Antwort. |
## Properties ## Properties
| Technischer Ident | Typ | Standard / Beschreibung | | Property | Standard | Beschreibung |
| --- | --- | --- | | --- | ---: | --- |
| `Geraeteart` | Auswahl | `PV`; alternativ `Batterie`. | | `Geraeteart` | PV | PV oder Batterie |
| `TopicSuffix` | String | leer; bestehender MQTT-Suffix. | | `TopicSuffix` | leer | Unveraenderter MQTT-Suffix |
| `ReqActionID` | Integer | `0`; bestehende Ausgabevariable/Nennleistung. | | `ReqActionID` | 0 | Bedienbare Zielvariable |
| `PowerProductionID` | Integer | `0`; aktuelle SDL-Leistung. | | `PowerProductionID` | 0 | Aktuelle SDL-/PV-Leistung |
| `SoCID` | Integer | `0`; nur bei Batterie. | | `SoCID` | 0 | Ladezustand im Batteriemodus |
| `TargetSoC` | Float | `50` %; nur bei Batterie. | | `TargetSoC` | 50 % | Zielwert fuer `restore` |
| `ChargePower` | Integer | `2500` W; nur bei Batterie. | | `ChargePower` | 2500 W | Ladeleistung fuer `restore` |
| `DischargePower` | Integer | `2500` W; nur bei Batterie. | | `DischargePower` | 2500 W | Entladeleistung fuer `restore` |
| `MaxPowerSetpoint` | Integer | `10000` W. | | `MaxPowerSetpoint` | 10000 W | Begrenzung eingehender Setpoints |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte ## Robustheit
- MQTT-Parent, Auftragsauswertung, Timer, Aktionen und vorhandener Testknopf - Zielvariablen muessen numerisch und bedienbar sein.
bleiben fachlicher Ausgangspunkt. - MQTT-Antworten werden in einer FIFO-Warteschlange verarbeitet; schnelle
- Passende MQTT-Verbindung zuordnen oder bei der Instanziierung anlegen. parallele Anfragen ueberschreiben sich nicht mehr.
- Keine neuen Zieladapter, Protokolle oder Datenqualitätsfelder in diesem Schritt. - Fehler der Zielaktion werden als Instanzstatus und Stoertext angezeigt.
- Optionale EMS-Anbindung darf keine feste Repository-Abhängigkeit erzeugen. - Ungueltige JSON-Nutzdaten werden ohne Hardwareaktion verworfen.
- MQTT-Payload und Topic-Vertrag bleiben dabei vollstaendig kompatibel.
## Inbetriebnahme
1. Vorhandenen MQTT-Parent zuordnen.
2. Geraeteart und bisherigen `TopicSuffix` uebernehmen.
3. Ziel- und Messvariablen konfigurieren.
4. Im Batteriemodus die Variablen der virtuellen Batterie verwenden.
5. `MinSoC`, `MaxSoC` und `TargetSoC` pruefen.
6. Zuerst `feedback-request`, danach `stop`, `activate` und
gegebenenfalls `restore` testen.
+105 -58
View File
@@ -1,68 +1,115 @@
# Virtuelle Batterie # Virtuelle Batterie
> Status: Diskussionsentwurf. Übernimmt `Bat_EV_SDL_V4` für die Bereiche > Status: Implementiert fuer IP-Symcon 8.0. Adaptiert das Grundkonzept aus
> Eigenverbrauch und SDL. > `Bat_EV_SDL_V4`, ohne dessen herstellerspezifische Typnamen zu uebernehmen.
Batterien und VGT-Kommunikation werden ausgewählt. Das Modul bleibt ohne EMS Das Modul fasst mehrere physische Batterien zu genau einer grossen virtuellen
nutzbar; eine EMS-Anbindung verwendet optional denselben Nachrichtenvertrag, Batterie zusammen. Diese Gesamtbatterie wird in einen Eigenverbrauchs- und
ohne Code-Abhängigkeit zwischen den Repositories. einen SDL-Anteil aufgeteilt. SDL hat bei der Leistungsverteilung Vorrang.
## Variablen ## Verantwortlichkeiten
| Ident | Typ / Zugriff | Beschreibung | ```text
| --- | --- | --- | Enelix Batterie-Modul -> EV-Proxyregister --+
| `Aktiv` | Boolean / bedienbar | Koordination aktivieren. | +-> Virtuelle Batterie -> physische Sollwerte
| `EigenverbrauchSollleistung` | Float / bedingt bedienbar | W; nur bei lokaler Quelle schreiben. | VGT-Schnittstelle ----> SDLSollleistung ----+
| `SDLSollleistung` | Float / Anzeige | W; nur aus gültigem zeitlichem Auftrag. | ```
| `EigenverbrauchIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `SDLIstleistung` | Float / Anzeige | W; zugeordneter Anteil. | Die virtuelle Batterie ist die einzige Instanz, die die konfigurierten
| `GesamtIstleistung` | Float / Anzeige | W; Summe gültiger physischer Messwerte. | physischen Sollleistungsvariablen beschreibt. Das bestehende EMS-Batteriemodul
| `Leistungsquelle` | Integer / Anzeige | `0` fehlt, `1` berechnet, `2` gemessen. | regelt den Eigenverbrauchsanteil. Die VGT-Schnittstelle regelt ausschliesslich
| `EigenverbrauchLadezustand` | Float / Anzeige | Virtueller Füllstand in %. | den SDL-Anteil.
| `SDLLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `EigenverbrauchMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | Positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen.
| `EigenverbrauchMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `SDLMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. | ## Physische Batterien
| `SDLMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `EigenverbrauchStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. | Die Property `Batterieliste` enthaelt pro Batterie:
| `SDLStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `VirtuelleKontenRuecksetzen` | Boolean / Impuls | `true` löst aus und wird wieder zurückgesetzt. | | Feld | Bedeutung |
| `Managerstatus` | Integer / Anzeige | Nur bei Managerquelle. | | --- | --- |
| `VGTStatus` | Integer / Anzeige | Nur bei VGT-Quelle. | | `Name` | Lesbare Kennung |
| `Stoerung` | Boolean / Anzeige | Eigene oder zugeordnete Fehler. | | `Kapazitaet_kWh` | Nutzbare Kapazitaet |
| `Stoertext` | String / Anzeige | Diagnose. | | `MaxLaden_W` | Maximale Ladeleistung |
| `MaxEntladen_W` | Maximale Entladeleistung |
| `LadezustandVariableID` | Aktueller Ladezustand in Prozent |
| `IstleistungVariableID` | Gemessene Leistung in W |
| `SollleistungVariableID` | Bedienbare Zielvariable mit Vorzeichen |
Der Gesamtladezustand ist kapazitaetsgewichtet. Beim Laden werden Batterien mit
tieferem Ladezustand zuerst verwendet, beim Entladen Batterien mit hoeherem
Ladezustand.
## Anschluss des bestehenden Batteriemoduls
Eine Enelix-EMS-Instanz **Batterie** wird als herstellerunabhaengige, von Enelix
gesteuerte Batterie eingerichtet. Ihre Messwerte und Register werden auf die
Variablen der virtuellen Batterie gelegt:
| Property im EMS-Batteriemodul | Variable der virtuellen Batterie |
| --- | --- |
| `MaxLadeleistungVariableID` | `EigenverbrauchMaxLaden` |
| `MaxEntladeleistungVariableID` | `EigenverbrauchMaxEntladen` |
| `LadezustandVariableID` | `EigenverbrauchLadezustand` |
| `IstleistungVariableID` | `EigenverbrauchIstleistung` |
| `ManagementRegisterVariableID` | `EVManagement` |
| `ModusRegisterVariableID` | `EVModus` |
| `LadeleistungRegisterVariableID` | `EVLadeleistung` |
| `EntladeleistungRegisterVariableID` | `EVEntladeleistung` |
Die Netzleistungsvariable bleibt die reale Messung des Anlagenanschlusspunktes.
Das Batteriemodul schreibt die vier Proxyregister atomar in seiner bestehenden
Reihenfolge. Erst das Managementregister uebernimmt den vollstaendigen
Eigenverbrauchsauftrag.
## Anschluss der VGT-Schnittstelle
| Property der VGT-Schnittstelle | Variable der virtuellen Batterie |
| --- | --- |
| `ReqActionID` | `SDLSollleistung` |
| `PowerProductionID` | `SDLIstleistung` |
| `SoCID` | `SDLLadezustand` |
Damit erreicht kein MQTT-Befehl direkt eine physische Batterie.
## Properties ## Properties
| Ident | Typ | Standard / Beschreibung | | Property | Standard | Beschreibung |
| --- | --- | --- | | --- | ---: | --- |
| `Batterieliste` | String/JSON | `[]`; Zielstelle, Kapazität, Grenzen und Messquellen je Batterie. | | `Batterieliste` | `[]` | Physische Batterien und Zielvariablen |
| `Eigenverbrauchsquelle` | Integer | `0` lokal, `1` externe Variable, `2` Managervertrag. | | `SDLReserveLaden` | 0 W | Fuer SDL reservierte Ladeleistung |
| `EigenverbrauchSollVariableID` | Integer | `0`; Pflicht bei Quelle 1. | | `SDLReserveEntladen` | 0 W | Fuer SDL reservierte Entladeleistung |
| `SDLQuelle` | Integer | `0` keine, `1` VGT-Instanz, `2` externer Auftragsdatensatz. | | `Reservezeit` | 0.5 h | Energetische Reserve je Richtung |
| `VGTInstanzID` | Integer | `0`; Pflicht bei SDLQuelle 1. | | `Aktualisierungsintervall` | 2 s | Zyklische Sicherheitsaktualisierung |
| `SDLAuftragVariableID` | Integer | `0`; Pflicht bei Quelle 2, inklusive Gültigkeit. | | `MesswertMaxAlter` | 30 s | Zulaessiges Alter physischer Messwerte |
| `SDLReserveLaden` | Float | `0` W. | | `VorgabeTimeout` | 30 s | Gueltigkeit der EV- und SDL-Vorgaben |
| `SDLReserveEntladen` | Float | `0` W. | | `EigenverbrauchStart` | 50 % | Startwert des EV-Kontos |
| `Reservezeit` | Float | `0.5` h; Bereich 0–24. | | `SDLStart` | 50 % | Startwert des SDL-Kontos |
| `Aktualisierungsintervall` | Integer | `2` s. | | `LoggingEin` | false | Debug-Ausgaben |
| `MesswertMaxAlter` | Integer | `30` s. |
| `Meldeintervall` | Integer | `10` s; bei Vertragsanbindung. |
| `VorgabeTimeout` | Integer | `30` s; bei weitergeleiteten Vorgaben. |
| `AusfallEigenverbrauch` | Integer | `0` auf 0 begrenzen, `1` lokale begrenzte Vorgabe. |
| `AusfallEigenverbrauchLeistung` | Float | `0` W; nur bei Ausfallmodus 1. |
| `FilterAktiv` | Boolean | `true`. |
| `FilterToleranz` | Float | `15` %. |
| `FilterRampe` | Float | `2000` W/s. |
| `FilterTreffer` | Integer | `1`. |
| `Abgleichintervall` | Float | `6` h. |
| `Abgleichtoleranz` | Float | `2` %. |
| `DiagnoseAnzeigen` | Boolean | `false`. |
| `PrioritaetPV` | Integer | `0`; nur bei Manageranbindung. |
| `PrioritaetPeak` | Integer | `0`; nur bei Manageranbindung. |
## Verhalten und offene Punkte ## Verhalten und Sicherheit
Eigenverbrauch und SDL ergeben zusammen genau einen begrenzten Batteriesollwert. - SDL wird vor dem Eigenverbrauch auf die reservierte Leistung begrenzt.
Pro physischer Batterie darf nur eine Stelle führen. Messgrundlage und - Gleichgerichtete Eigenverbrauchsleistung erhaelt nur die verbleibende
Aufteilung der berechneten Leistungsanteile sind im Test eindeutig zu belegen. physische Leistung.
- Gegenlaeufige Auftraege werden bilanziell getrennt und physisch als
Nettosollwert verteilt.
- Fehlende oder veraltete Batterien werden aus der aktuellen Aggregation
entfernt und auf 0 W gesetzt.
- Bei Deaktivierung, ungueltiger Konfiguration oder ohne gueltige Batterie
werden alle physischen Sollleistungsvariablen auf 0 W gesetzt.
- Eine erkannte Integrationsluecke wird gemeldet und nicht still auf eine
kuerzere Dauer begrenzt.
- Physische Zielvariablen werden ausschliesslich mit `RequestAction`
geschrieben.
## Inbetriebnahme
1. Mess- und Sollleistungsvariablen aller physischen Batterien festlegen.
2. Batterieliste konfigurieren und Messwertalter kontrollieren.
3. SDL-Leistungsreserve und Reservezeit eintragen.
4. Das vorhandene EMS-Batteriemodul auf die EV-Proxyregister konfigurieren.
5. Die VGT-Schnittstelle auf die drei SDL-Variablen konfigurieren.
6. Zuerst mit deaktivierter virtueller Batterie alle Vorzeichen pruefen.
7. Danach Eigenverbrauch und SDL einzeln testen.
8. Erst abschliessend gleich- und gegenlaeufige Auftraege pruefen.
+2 -2
View File
@@ -13,10 +13,10 @@ final class DokumentationsstrukturTest extends TestCase
{ {
return [ return [
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Implementiert'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'], 'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'],
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'], 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Implementiert'],
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
]; ];
} }
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use PHPUnit\Framework\TestCase;
final class NeueModuleStrukturTest extends TestCase
{
/** @dataProvider module */
public function testMetadatenUndFormulareSindGueltig(
string $ordner,
string $name,
string $prefix
): void {
$metadata = json_decode(
(string) file_get_contents(
__DIR__ . '/../' . $ordner . '/module.json'
),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame($name, $metadata['name']);
self::assertSame($prefix, $metadata['prefix']);
self::assertSame(3, $metadata['type']);
self::assertMatchesRegularExpression(
'/^\{[0-9A-F-]{36}\}$/',
$metadata['id']
);
$formular = json_decode(
(string) file_get_contents(
__DIR__ . '/../' . $ordner . '/form.json'
),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertNotEmpty($formular['elements']);
}
/** @return array<string, array{string, string, string}> */
public function module(): array
{
return [
'Virtuelle Batterie' => [
'VirtuelleBatterie',
'Virtuelle Batterie',
'ENLVB',
],
'VGT-Schnittstelle' => [
'VGTSchnittstelle',
'VGTSchnittstelle',
'ENLVGT',
],
];
}
public function testVirtuelleBatterieStelltEVProxyregisterBereit(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../VirtuelleBatterie/module.php'
);
foreach ([
"'EVManagement'",
"'EVModus'",
"'EVLadeleistung'",
"'EVEntladeleistung'",
"'SDLSollleistung'",
'VirtuelleBatterieRegler::kombiniere',
] as $bestandteil) {
self::assertStringContainsString($bestandteil, $modul);
}
}
public function testPhysischeSollwerteWerdenNurPerActionGeschrieben(): void
{
$modul = (string) file_get_contents(
__DIR__ . '/../VirtuelleBatterie/module.php'
);
$anfang = strpos($modul, 'private function schreibeVerteilung');
$ende = strpos($modul, 'private function sichererZustand');
self::assertNotFalse($anfang);
self::assertNotFalse($ende);
$funktion = substr($modul, $anfang, $ende - $anfang);
self::assertStringContainsString('RequestAction(', $funktion);
self::assertStringNotContainsString('SetValue($zielID', $funktion);
}
}
+12
View File
@@ -15,6 +15,18 @@ return [
'Energiediagramm/*', 'Energiediagramm/*',
], ],
], ],
'VGTSchnittstelle' => [
'file' => __DIR__ . '/modules/VGTSchnittstelle.php',
'paths' => [
'VGTSchnittstelle/*',
],
],
'VirtuelleBatterie' => [
'file' => __DIR__ . '/modules/VirtuelleBatterie.php',
'paths' => [
'VirtuelleBatterie/*',
],
],
'ShellyModul' => [ 'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php', 'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [ 'paths' => [
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$instancesBefore = array_flip(IPS_GetInstanceList());
$mqttId = $test->createInstance(
'{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}',
'MQTT-Testserver'
);
$ioId = (int) IPS_GetInstance($mqttId)['ConnectionID'];
if ($ioId > 0 && !isset($instancesBefore[$ioId])) {
IPS_SetParent($ioId, $test->rootId());
}
$moduleId = $test->createInstance(
'{4BA1A557-7970-4FB9-93B8-1F6ADA0A5A94}',
'VGT-Schnittstelle'
);
if ((int) IPS_GetInstance($moduleId)['ConnectionID'] !== $mqttId) {
IPS_DisconnectInstance($moduleId);
IPS_ConnectInstance($moduleId, $mqttId);
IPS_ApplyChanges($moduleId);
}
$test->runCase('MQTT-Elterninstanz wird verwendet', static function (
TestContext $test
) use ($moduleId, $mqttId): void {
$test->assertSame(
$mqttId,
(int) IPS_GetInstance($moduleId)['ConnectionID']
);
});
$test->runCase('Konfigurationsformular ist gueltiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
};
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$moduleId = $test->createInstance(
'{5B2A199B-2226-411F-B83B-FC1CBA58496B}',
'Virtuelle Batterie'
);
$test->runCase('Konfigurationsformular ist gueltiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
$test->runCase('Sichere Startkonfiguration ist inaktiv', static function (
TestContext $test
) use ($moduleId): void {
$aktivId = IPS_GetObjectIDByIdent('Aktiv', $moduleId);
$test->assertSame(false, GetValue($aktivId));
});
};
+8 -1
View File
@@ -58,7 +58,14 @@ final class SymconTestContractTest extends TestCase
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{ {
self::assertSame( self::assertSame(
['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], [
'CC100Hardware',
'Energiediagramm',
'ShellyModul',
'VGTSchnittstelle',
'Verbrauchskostenreport',
'VirtuelleBatterie',
],
$this->resolveAffectedModules(['tests/Symcon/TestContext.php']) $this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
); );
} }
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\VGTRegler;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../VGTSchnittstelle/libs/VGTRegler.php';
final class VGTReglerTest extends TestCase
{
public function testPVActivateUndStopBleibenKompatibel(): void
{
self::assertSame(
4000.0,
VGTRegler::zielwert(0, 'activate', 4000.0, 10000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0)
);
self::assertSame(
10000.0,
VGTRegler::zielwert(0, 'stop', 4000.0, 10000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 100.0)
);
}
public function testBatterieActivateInvertiertDenSetpointWieEnelixEins(): void
{
self::assertSame(
-3500.0,
VGTRegler::zielwert(1, 'activate', 3500.0, 10000.0, 50.0, 50.0, 2500.0, 2500.0, 10.0, 90.0)
);
}
public function testRestoreRegeltAufZielLadezustand(): void
{
self::assertSame(
2500.0,
VGTRegler::zielwert(1, 'restore', 0.0, 10000.0, 40.0, 50.0, 2500.0, 3000.0, 10.0, 90.0)
);
self::assertSame(
-3000.0,
VGTRegler::zielwert(1, 'restore', 0.0, 10000.0, 60.0, 50.0, 2500.0, 3000.0, 10.0, 90.0)
);
}
public function testLadezustandsgrenzenStoppenFalscheRichtung(): void
{
self::assertSame(
0.0,
VGTRegler::zielwert(1, 'activate', 3000.0, 10000.0, 10.0, 50.0, 2500.0, 2500.0, 10.0, 90.0)
);
}
public function testFesteMQTTSchnittstelleBleibtImModulErhalten(): void
{
$modul = file_get_contents(
__DIR__ . '/../VGTSchnittstelle/module.php'
);
self::assertNotFalse($modul);
foreach ([
'feedback-request/',
'feedback-response/',
'remote-control-request/',
'remote-control-response/',
"'power_setpoint'",
"'strategy'",
"'power_production'",
"'is_ready'",
"'is_running'",
"'state_of_charge'",
"'min_soc'",
"'max_soc'",
] as $bestandteil) {
self::assertStringContainsString($bestandteil, $modul);
}
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\Tests;
use Belevo\EnelixUtils\VirtuelleBatterieRegler;
use PHPUnit\Framework\TestCase;
require_once __DIR__
. '/../VirtuelleBatterie/libs/VirtuelleBatterieRegler.php';
final class VirtuelleBatterieReglerTest extends TestCase
{
public function testAggregatIstKapazitaetsgewichtet(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 10.0, 20.0, 5000.0, 5000.0),
$this->batterie(1, 30.0, 80.0, 10000.0, 8000.0),
], 3000.0, 2000.0, 0.5);
self::assertSame(40.0, $aggregat['Kapazitaet_kWh']);
self::assertSame(65.0, $aggregat['Ladezustand_Prozent']);
self::assertSame(15000.0, $aggregat['MaxLaden_W']);
self::assertSame(13000.0, $aggregat['MaxEntladen_W']);
self::assertSame(2.5, $aggregat['SDLKapazitaet_kWh']);
self::assertSame(37.5, $aggregat['EigenverbrauchKapazitaet_kWh']);
}
public function testSDLHatBeiGleicherRichtungVorrang(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 20.0, 50.0, 10000.0, 10000.0),
], 6000.0, 6000.0, 0.5);
$auftrag = VirtuelleBatterieRegler::kombiniere(
8000.0,
6000.0,
$aggregat
);
self::assertSame(6000.0, $auftrag['SDL_W']);
self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']);
self::assertSame(10000.0, $auftrag['Gesamt_W']);
}
public function testGegenlaeufigeAuftraegeWerdenNettoVerteilt(): void
{
$aggregat = VirtuelleBatterieRegler::aggregiere([
$this->batterie(0, 20.0, 50.0, 10000.0, 10000.0),
], 6000.0, 6000.0, 0.5);
$auftrag = VirtuelleBatterieRegler::kombiniere(
4000.0,
-3000.0,
$aggregat
);
self::assertSame(1000.0, $auftrag['Gesamt_W']);
self::assertSame(-3000.0, $auftrag['SDL_W']);
self::assertSame(4000.0, $auftrag['Eigenverbrauch_W']);
}
public function testLadenBeginntBeiNiedrigstemLadezustand(): void
{
$verteilung = VirtuelleBatterieRegler::verteile(7000.0, [
$this->batterie(0, 10.0, 80.0, 5000.0, 5000.0),
$this->batterie(1, 10.0, 20.0, 5000.0, 5000.0),
]);
self::assertSame(2000.0, $verteilung[0]);
self::assertSame(5000.0, $verteilung[1]);
}
public function testIstleistungOrdnetSDLZuerstZu(): void
{
self::assertSame(
['Eigenverbrauch_W' => 4000.0, 'SDL_W' => -3000.0],
VirtuelleBatterieRegler::ordneIstleistungZu(1000.0, -3000.0)
);
}
/**
* @return array{
* Index:int,
* Name:string,
* Kapazitaet_kWh:float,
* MaxLaden_W:float,
* MaxEntladen_W:float,
* Ladezustand_Prozent:float,
* Istleistung_W:float
* }
*/
private function batterie(
int $index,
float $kapazitaet,
float $ladezustand,
float $maxLaden,
float $maxEntladen
): array {
return [
'Index' => $index,
'Name' => 'Batterie ' . $index,
'Kapazitaet_kWh' => $kapazitaet,
'MaxLaden_W' => $maxLaden,
'MaxEntladen_W' => $maxEntladen,
'Ladezustand_Prozent' => $ladezustand,
'Istleistung_W' => 0.0,
];
}
}