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
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, CC100 Hardware,
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
Das Repository befindet sich im Aufbau. Verbrauchskostenreport, Virtuelle
Batterie, CC100 Hardware, Energiediagramm, VGT-Schnittstelle und Shelly Modul
sind als installierbare IP-Symcon-Module enthalten.
## Module
- Verbrauchskostenreport (implementiert)
- Virtuelle Batterie
- Virtuelle Batterie (implementiert)
- CC100 Hardware (implementiert)
- Energiediagramm (implementiert)
- VGT-Schnittstelle
- VGT-Schnittstelle (implementiert)
- Shelly Modul (implementiert)
Die vollständigen Tabellen mit Properties, Variablen, Verhalten und offenen
@@ -62,7 +62,9 @@ Module geladen sein:
- `CC100 Hardware`
- `Energiediagramm`
- `Shelly Modul`
- `VGT-Schnittstelle`
- `Verbrauchskostenreport`
- `Virtuelle Batterie`
Ein korrekter Zustand zeigt die Repository-URL, den ausgewählten Branch und
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
> Status: Verbrauchskostenreport, CC100 Hardware, Energiediagramm und Shelly
> Modul sind implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
> Status: Alle dokumentierten Enelix-Utils-Module sind implementiert.
| Modul | Herkunft | EMS-Abhängigkeit |
| --- | --- | --- |
| [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 |
| [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 |
Utils-Module beeinflussen sich nicht gegenseitig. Prognose, Lizenzierung und
Störüberwachung sind keine Module dieses Repositories.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Erst nach der
Teamfreigabe erhält ein Modul `module.json`, `form.json`, `module.php` und
zugehörige Unit-Tests.
`0*` bezeichnet einen noch nicht eingerichteten Anlagenwert. Jedes Modul
besitzt `module.json`, `form.json`, `module.php`, Unit-Tests und einen
registrierten IP-Symcon-Laufzeittest.
+92 -31
View File
@@ -1,42 +1,103 @@
# VGT-Schnittstelle
> Status: Diskussionsentwurf. Führt `MQTTPVSDL` und die vorhandenen
> Batterie-Felder aus `MQTTBatterySDL` zusammen.
> Status: Implementiert fuer IP-Symcon 8.0. Fuehrt `MQTTPVSDL` und
> `MQTTBatterySDL` zusammen.
Die bestehende MQTT-Arbeitsweise und technische Alt-Idents bleiben erhalten.
Die Geräteart PV oder Batterie steuert nur die sichtbaren Felder.
Die bestehende MQTT-Schnittstelle wurde unveraendert uebernommen. Weder
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. |
| `IsRunning` | Boolean / Anzeige | Bestehender Bearbeitungsstatus. |
| `MinSoC` | Float / Anzeige | Nur Batterie; untere Ladezustandsgrenze. |
| `MaxSoC` | Float / Anzeige | Nur Batterie; obere Ladezustandsgrenze. |
| `PowerSetpoint` | Integer / bedienbar | Bestehende Leistungsvorgabe und Testaktion. |
| `Strategy` | String / bedienbar | Bestehende Strategie und Testaktion. |
| `LastReadResponse` | String / Anzeige | Letzte Lese-Antwort. |
| `LastWriteResponse` | String / Anzeige | Letzte Steuer-Antwort. |
| `activate` | Setpoint zwischen 0 und Maximum | invertierter Setpoint |
| `stop` | Freigabe auf konfiguriertes Maximum | 0 W |
| `restore` | keine aktive Fernbegrenzung | Regelung auf `TargetSoC` |
Im Batteriemodus verhindern `MinSoC` und `MaxSoC` eine Vorgabe in die
falsche Richtung an der jeweiligen Ladezustandsgrenze.
## Properties
| Technischer Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Geraeteart` | Auswahl | `PV`; alternativ `Batterie`. |
| `TopicSuffix` | String | leer; bestehender MQTT-Suffix. |
| `ReqActionID` | Integer | `0`; bestehende Ausgabevariable/Nennleistung. |
| `PowerProductionID` | Integer | `0`; aktuelle SDL-Leistung. |
| `SoCID` | Integer | `0`; nur bei Batterie. |
| `TargetSoC` | Float | `50` %; nur bei Batterie. |
| `ChargePower` | Integer | `2500` W; nur bei Batterie. |
| `DischargePower` | Integer | `2500` W; nur bei Batterie. |
| `MaxPowerSetpoint` | Integer | `10000` W. |
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Geraeteart` | PV | PV oder Batterie |
| `TopicSuffix` | leer | Unveraenderter MQTT-Suffix |
| `ReqActionID` | 0 | Bedienbare Zielvariable |
| `PowerProductionID` | 0 | Aktuelle SDL-/PV-Leistung |
| `SoCID` | 0 | Ladezustand im Batteriemodus |
| `TargetSoC` | 50 % | Zielwert fuer `restore` |
| `ChargePower` | 2500 W | Ladeleistung fuer `restore` |
| `DischargePower` | 2500 W | Entladeleistung fuer `restore` |
| `MaxPowerSetpoint` | 10000 W | Begrenzung eingehender Setpoints |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte
## Robustheit
- MQTT-Parent, Auftragsauswertung, Timer, Aktionen und vorhandener Testknopf
bleiben fachlicher Ausgangspunkt.
- Passende MQTT-Verbindung zuordnen oder bei der Instanziierung anlegen.
- Keine neuen Zieladapter, Protokolle oder Datenqualitätsfelder in diesem Schritt.
- Optionale EMS-Anbindung darf keine feste Repository-Abhängigkeit erzeugen.
- Zielvariablen muessen numerisch und bedienbar sein.
- MQTT-Antworten werden in einer FIFO-Warteschlange verarbeitet; schnelle
parallele Anfragen ueberschreiben sich nicht mehr.
- Fehler der Zielaktion werden als Instanzstatus und Stoertext angezeigt.
- 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
> Status: Diskussionsentwurf. Übernimmt `Bat_EV_SDL_V4` für die Bereiche
> Eigenverbrauch und SDL.
> Status: Implementiert fuer IP-Symcon 8.0. Adaptiert das Grundkonzept aus
> `Bat_EV_SDL_V4`, ohne dessen herstellerspezifische Typnamen zu uebernehmen.
Batterien und VGT-Kommunikation werden ausgewählt. Das Modul bleibt ohne EMS
nutzbar; eine EMS-Anbindung verwendet optional denselben Nachrichtenvertrag,
ohne Code-Abhängigkeit zwischen den Repositories.
Das Modul fasst mehrere physische Batterien zu genau einer grossen virtuellen
Batterie zusammen. Diese Gesamtbatterie wird in einen Eigenverbrauchs- und
einen SDL-Anteil aufgeteilt. SDL hat bei der Leistungsverteilung Vorrang.
## Variablen
## Verantwortlichkeiten
| Ident | Typ / Zugriff | Beschreibung |
| --- | --- | --- |
| `Aktiv` | Boolean / bedienbar | Koordination aktivieren. |
| `EigenverbrauchSollleistung` | Float / bedingt bedienbar | W; nur bei lokaler Quelle schreiben. |
| `SDLSollleistung` | Float / Anzeige | W; nur aus gültigem zeitlichem Auftrag. |
| `EigenverbrauchIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `SDLIstleistung` | Float / Anzeige | W; zugeordneter Anteil. |
| `GesamtIstleistung` | Float / Anzeige | W; Summe gültiger physischer Messwerte. |
| `Leistungsquelle` | Integer / Anzeige | `0` fehlt, `1` berechnet, `2` gemessen. |
| `EigenverbrauchLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `SDLLadezustand` | Float / Anzeige | Virtueller Füllstand in %. |
| `EigenverbrauchMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. |
| `EigenverbrauchMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `SDLMaxLaden` | Float / Anzeige | Verfügbare Ladeleistung in W. |
| `SDLMaxEntladen` | Float / Anzeige | Verfügbare Entladeleistung in W. |
| `EigenverbrauchStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `SDLStart` | Float / bedienbar | Startwert 0–100 % für explizites Rücksetzen. |
| `VirtuelleKontenRuecksetzen` | Boolean / Impuls | `true` löst aus und wird wieder zurückgesetzt. |
| `Managerstatus` | Integer / Anzeige | Nur bei Managerquelle. |
| `VGTStatus` | Integer / Anzeige | Nur bei VGT-Quelle. |
| `Stoerung` | Boolean / Anzeige | Eigene oder zugeordnete Fehler. |
| `Stoertext` | String / Anzeige | Diagnose. |
```text
Enelix Batterie-Modul -> EV-Proxyregister --+
+-> Virtuelle Batterie -> physische Sollwerte
VGT-Schnittstelle ----> SDLSollleistung ----+
```
Die virtuelle Batterie ist die einzige Instanz, die die konfigurierten
physischen Sollleistungsvariablen beschreibt. Das bestehende EMS-Batteriemodul
regelt den Eigenverbrauchsanteil. Die VGT-Schnittstelle regelt ausschliesslich
den SDL-Anteil.
Positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen.
## Physische Batterien
Die Property `Batterieliste` enthaelt pro Batterie:
| Feld | Bedeutung |
| --- | --- |
| `Name` | Lesbare Kennung |
| `Kapazitaet_kWh` | Nutzbare Kapazitaet |
| `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
| Ident | Typ | Standard / Beschreibung |
| --- | --- | --- |
| `Batterieliste` | String/JSON | `[]`; Zielstelle, Kapazität, Grenzen und Messquellen je Batterie. |
| `Eigenverbrauchsquelle` | Integer | `0` lokal, `1` externe Variable, `2` Managervertrag. |
| `EigenverbrauchSollVariableID` | Integer | `0`; Pflicht bei Quelle 1. |
| `SDLQuelle` | Integer | `0` keine, `1` VGT-Instanz, `2` externer Auftragsdatensatz. |
| `VGTInstanzID` | Integer | `0`; Pflicht bei SDLQuelle 1. |
| `SDLAuftragVariableID` | Integer | `0`; Pflicht bei Quelle 2, inklusive Gültigkeit. |
| `SDLReserveLaden` | Float | `0` W. |
| `SDLReserveEntladen` | Float | `0` W. |
| `Reservezeit` | Float | `0.5` h; Bereich 0–24. |
| `Aktualisierungsintervall` | Integer | `2` s. |
| `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. |
| Property | Standard | Beschreibung |
| --- | ---: | --- |
| `Batterieliste` | `[]` | Physische Batterien und Zielvariablen |
| `SDLReserveLaden` | 0 W | Fuer SDL reservierte Ladeleistung |
| `SDLReserveEntladen` | 0 W | Fuer SDL reservierte Entladeleistung |
| `Reservezeit` | 0.5 h | Energetische Reserve je Richtung |
| `Aktualisierungsintervall` | 2 s | Zyklische Sicherheitsaktualisierung |
| `MesswertMaxAlter` | 30 s | Zulaessiges Alter physischer Messwerte |
| `VorgabeTimeout` | 30 s | Gueltigkeit der EV- und SDL-Vorgaben |
| `EigenverbrauchStart` | 50 % | Startwert des EV-Kontos |
| `SDLStart` | 50 % | Startwert des SDL-Kontos |
| `LoggingEin` | false | Debug-Ausgaben |
## Verhalten und offene Punkte
## Verhalten und Sicherheit
Eigenverbrauch und SDL ergeben zusammen genau einen begrenzten Batteriesollwert.
Pro physischer Batterie darf nur eine Stelle führen. Messgrundlage und
Aufteilung der berechneten Leistungsanteile sind im Test eindeutig zu belegen.
- SDL wird vor dem Eigenverbrauch auf die reservierte Leistung begrenzt.
- Gleichgerichtete Eigenverbrauchsleistung erhaelt nur die verbleibende
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 [
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Implementiert'],
'CC100 Hardware' => ['CC100-Hardware', 'Status: Implementiert'],
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
'VGT-Schnittstelle' => ['VGT-Schnittstelle', '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/*',
],
],
'VGTSchnittstelle' => [
'file' => __DIR__ . '/modules/VGTSchnittstelle.php',
'paths' => [
'VGTSchnittstelle/*',
],
],
'VirtuelleBatterie' => [
'file' => __DIR__ . '/modules/VirtuelleBatterie.php',
'paths' => [
'VirtuelleBatterie/*',
],
],
'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php',
'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
{
self::assertSame(
['CC100Hardware', 'Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
[
'CC100Hardware',
'Energiediagramm',
'ShellyModul',
'VGTSchnittstelle',
'Verbrauchskostenreport',
'VirtuelleBatterie',
],
$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,
];
}
}