This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
@@ -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);"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "{5B2A199B-2226-411F-B83B-FC1CBA58496B}",
|
||||
"name": "Virtuelle Batterie",
|
||||
"type": 3,
|
||||
"vendor": "Enelix",
|
||||
"aliases": [],
|
||||
"parentRequirements": [],
|
||||
"childRequirements": [],
|
||||
"implemented": [],
|
||||
"prefix": "ENLVB",
|
||||
"url": ""
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
@@ -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'])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user