Enelix-2-Manager implementieren
Tests / test (push) Canceled after 0s

Installierbares IP-Symcon-Manager-Modul mit testbarer Verteilungslogik, Verbraucher-Timeouts, Sollwertversand und aktualisierter Dokumentation.
This commit is contained in:
dh
2026-09-15 15:59:25 +00:00
parent 21a6466ca5
commit 5f1bdbc0fa
10 changed files with 1175 additions and 11 deletions
+171
View File
@@ -0,0 +1,171 @@
{
"elements": [
{
"type": "Select",
"name": "Rolle",
"caption": "Rolle",
"options": [
{"caption": "Alleine", "value": 0},
{"caption": "Hauptmanager", "value": 1},
{"caption": "Untermanager", "value": 2}
]
},
{
"type": "SelectVariable",
"name": "NetzleistungVariableID",
"caption": "Netzleistung"
},
{
"type": "NumberSpinner",
"name": "Netzleistungsfaktor",
"caption": "Netzleistungsfaktor",
"digits": 4
},
{
"type": "NumberSpinner",
"name": "MesswertMaxAlter",
"caption": "Maximales Messwertalter",
"suffix": " s",
"minimum": 1
},
{
"type": "List",
"name": "VerbraucherZuordnung",
"caption": "Verbraucher",
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "Instanz",
"name": "InstanzID",
"width": "auto",
"add": 0,
"edit": {"type": "SelectInstance"}
},
{
"caption": "Aktiv",
"name": "Aktiv",
"width": "100px",
"add": false,
"edit": {"type": "CheckBox"}
}
]
},
{
"type": "CheckBox",
"name": "AutomatischeSuche",
"caption": "Verbraucher automatisch suchen"
},
{
"type": "SelectObject",
"name": "SuchbereichID",
"caption": "Suchbereich"
},
{
"type": "NumberSpinner",
"name": "Regelintervall",
"caption": "Regelintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "Vorgabeintervall",
"caption": "Vorgabeintervall",
"suffix": " s",
"minimum": 1
},
{
"type": "NumberSpinner",
"name": "VerbraucherTimeout",
"caption": "Verbraucher-Timeout",
"suffix": " s",
"minimum": 1
},
{
"type": "Select",
"name": "Lastspitzenmodus",
"caption": "Lastspitzenmodus",
"options": [
{"caption": "Aus", "value": 0},
{"caption": "Konstant", "value": 1},
{"caption": "Monatlich", "value": 2}
]
},
{
"type": "NumberSpinner",
"name": "Lastspitzengrenze",
"caption": "Lastspitzengrenze",
"suffix": " W",
"digits": 1
},
{
"type": "ValidationTextBox",
"name": "Monatsgrenzen",
"caption": "Monatsgrenzen als JSON"
},
{
"type": "CheckBox",
"name": "MonatsgrenzenVisuBedienbar",
"caption": "Monatsgrenzen in der Visualisierung bedienen"
},
{
"type": "NumberSpinner",
"name": "SollwertSolarladen",
"caption": "Sollwert Solarladen",
"suffix": " W",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "UmschaltschwelleUeberschuss",
"caption": "Umschaltschwelle Ueberschuss",
"suffix": " W",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "UmschaltschwelleLastspitze",
"caption": "Umschaltschwelle Lastspitze",
"suffix": " W",
"digits": 1
},
{
"type": "NumberSpinner",
"name": "Umschaltdifferenz",
"caption": "Mindestdifferenz vor Umschaltung",
"suffix": " %",
"minimum": 0,
"maximum": 100,
"digits": 1
},
{
"type": "ExpansionPanel",
"caption": "Optionale obere Anschluesse",
"items": [
{"type": "ValidationTextBox", "name": "PrognoseAnschluss", "caption": "Prognose / Forecast als JSON"},
{"type": "ValidationTextBox", "name": "SDLAnschluss", "caption": "SDL / VGT als JSON"},
{"type": "ValidationTextBox", "name": "LizenzAnschluss", "caption": "Lizenzierung als JSON"},
{"type": "ValidationTextBox", "name": "StoermeldeAnschluss", "caption": "Stoerueberwachung als JSON"}
]
},
{
"type": "CheckBox",
"name": "LoggingEin",
"caption": "Logging ein"
}
],
"actions": [
{
"type": "Button",
"caption": "Regelung jetzt ausfuehren",
"onClick": "IPS_RequestAction($id, 'Regeln', true);"
}
],
"status": [
{"code": 102, "icon": "active", "caption": "Aktiv"},
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}",
"name": "Manager",
"type": 3,
"vendor": "Belevo AG",
"aliases": [
"Enelix 2 Manager"
],
"parentRequirements": [],
"childRequirements": [],
"implemented": [],
"prefix": "ENELIX",
"url": "https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/develop/Manager"
}
+468
View File
@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/ManagerRegler.php';
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
use Belevo\EnelixEMS\Nachrichtenvertrag;
class Manager extends IPSModule implements ManagerSchnittstelle
{
private const STATUS_AKTIV = 102;
private const STATUS_MESSWERT_UNGUELTIG = 201;
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
private const VM_UPDATE = 10603;
public function Create(): void
{
parent::Create();
$this->RegisterPropertyInteger('Rolle', 0);
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
$this->RegisterPropertyFloat('Netzleistungsfaktor', 1.0);
$this->RegisterPropertyInteger('MesswertMaxAlter', 30);
$this->RegisterPropertyString('VerbraucherZuordnung', '[]');
$this->RegisterPropertyBoolean('AutomatischeSuche', true);
$this->RegisterPropertyInteger('SuchbereichID', 0);
$this->RegisterPropertyInteger('Regelintervall', 2);
$this->RegisterPropertyInteger('Vorgabeintervall', 10);
$this->RegisterPropertyInteger('VerbraucherTimeout', 30);
$this->RegisterPropertyInteger('Lastspitzenmodus', 0);
$this->RegisterPropertyFloat('Lastspitzengrenze', 0.0);
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
$this->RegisterPropertyBoolean('MonatsgrenzenVisuBedienbar', false);
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
$this->RegisterPropertyFloat('UmschaltschwelleUeberschuss', 0.0);
$this->RegisterPropertyFloat('UmschaltschwelleLastspitze', 0.0);
$this->RegisterPropertyFloat('Umschaltdifferenz', 0.0);
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('LizenzAnschluss', '{}');
$this->RegisterPropertyString('StoermeldeAnschluss', '{}');
$this->RegisterPropertyBoolean('LoggingEin', false);
$this->RegisterVariableBoolean('Aktiv', 'Aktiv', '~Switch', 10);
$this->EnableAction('Aktiv');
$this->RegisterVariableString('Betriebsart', 'Betriebsart', '', 20);
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
$this->RegisterVariableBoolean('NetzleistungGueltig', 'Netzleistung gueltig', '~Switch', 40);
$this->RegisterVariableFloat('WirksameLastspitzengrenze', 'Wirksame Lastspitzengrenze', '', 50);
$this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60);
$this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70);
$this->RegisterVariableInteger('VerbraucherVerfuegbar', 'Verbraucher verfuegbar', '', 80);
$this->RegisterVariableString('Verbraucherstatus', 'Verbraucherstatus', '', 90);
$this->RegisterVariableString('Monatsgrenzen', 'Monatsgrenzen', '', 100);
$this->RegisterVariableString('Prognosestatus', 'Prognosestatus', '', 110);
$this->RegisterVariableString('SDLStatus', 'SDL-Status', '', 120);
$this->RegisterVariableString('Lizenzstatus', 'Lizenzstatus', '', 130);
$this->RegisterVariableString('Stoerueberwachungsstatus', 'Stoerueberwachungsstatus', '', 140);
$this->RegisterVariableBoolean('Sammelstoerung', 'Sammelstoerung', '~Alert', 150);
$this->RegisterVariableString('Stoertext', 'Stoertext', '', 160);
$this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0);
$this->RegisterTimer(
'Regelzyklus',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', false);"
);
$this->RegisterTimer(
'VorgabenErneuern',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
);
}
public function ApplyChanges(): void
{
parent::ApplyChanges();
try {
$this->pruefeKonfiguration();
} catch (Throwable $fehler) {
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->protokolliere('Konfiguration', $fehler->getMessage());
return;
}
$this->registriereNetzleistungsmeldung();
$this->SetTimerInterval('Regelzyklus', $this->ReadPropertyInteger('Regelintervall') * 1000);
$this->SetTimerInterval('VorgabenErneuern', $this->ReadPropertyInteger('Vorgabeintervall') * 1000);
$this->SetValue('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
$this->aktualisiereSichtbarkeit();
$this->aktualisiereAnschlussstatus();
$this->SetStatus(self::STATUS_AKTIV);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
&& (int) $nachricht === self::VM_UPDATE
) {
$this->regeln(false);
}
}
public function RequestAction($ident, $wert): void
{
switch ($ident) {
case 'Aktiv':
$this->SetValue('Aktiv', (bool) $wert);
$this->regeln(false);
return;
case 'Regeln':
$this->regeln((bool) $wert);
return;
case 'VerbraucherdatenEmpfangen':
if (!is_string($wert)) {
throw new InvalidArgumentException('Verbraucherdaten muessen als JSON uebergeben werden.');
}
$daten = json_decode($wert, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)) {
throw new InvalidArgumentException('Verbraucherdaten muessen ein JSON-Objekt sein.');
}
$this->VerbraucherdatenEmpfangen($daten);
return;
case 'Monatsgrenzen':
if (!$this->ReadPropertyBoolean('MonatsgrenzenVisuBedienbar')) {
throw new RuntimeException('Monatsgrenzen sind in der Visualisierung nicht bedienbar.');
}
$this->dekodiereMonatsgrenzen((string) $wert);
IPS_SetProperty($this->InstanceID, 'Monatsgrenzen', (string) $wert);
IPS_ApplyChanges($this->InstanceID);
return;
}
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
}
/**
* @param array<string, mixed> $daten
*/
public function VerbraucherdatenEmpfangen(array $daten): void
{
Nachrichtenvertrag::pruefeVerbraucherdaten($daten);
if ($daten['Kopf']['EmpfaengerID'] !== $this->InstanceID) {
throw new InvalidArgumentException('Verbraucherdaten sind an eine andere Instanz adressiert.');
}
$absenderID = $daten['Kopf']['AbsenderID'];
if (!in_array($absenderID, $this->aktiveVerbraucherIDs(), true)) {
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
}
$cache = $this->leseJsonAttribut('VerbraucherCache');
$cache[(string) $absenderID] = [
'EmpfangenAm' => time(),
'Daten' => $daten,
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->regeln(false);
}
private function regeln(bool $erneuern): void
{
[$netzleistung, $netzleistungGueltig] = $this->leseNetzleistung();
$this->SetValue('Netzleistung', $netzleistung);
$this->SetValue('NetzleistungGueltig', $netzleistungGueltig);
$aktiv = (bool) $this->GetValue('Aktiv');
if (!$aktiv || !$netzleistungGueltig) {
$this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV);
$this->SetValue('Verteilbudget', 0.0);
$this->SetStatus($aktiv ? self::STATUS_MESSWERT_UNGUELTIG : self::STATUS_AKTIV);
return;
}
$stoerungen = [];
$verbraucher = $this->aktuelleVerbraucher($stoerungen);
$lastspitzengrenze = ManagerRegler::wirksameLastspitzengrenze(
$this->ReadPropertyInteger('Lastspitzenmodus'),
$this->ReadPropertyFloat('Lastspitzengrenze'),
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')),
(int) date('n')
);
$betriebsart = ManagerRegler::bestimmeBetriebsart(
true,
true,
$netzleistung,
(string) $this->GetValue('Betriebsart'),
$lastspitzengrenze !== null,
$this->ReadPropertyFloat('UmschaltschwelleUeberschuss'),
$this->ReadPropertyFloat('UmschaltschwelleLastspitze'),
$this->ReadPropertyFloat('SollwertSolarladen'),
$lastspitzengrenze ?? 0.0,
$this->ReadPropertyFloat('Umschaltdifferenz')
);
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
? (float) $lastspitzengrenze
: $this->ReadPropertyFloat('SollwertSolarladen');
$ergebnis = ManagerRegler::verteile(
$netzleistung,
$zielnetzleistung,
$verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV'
);
$this->SetValue('Betriebsart', $betriebsart);
$this->SetValue('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
$this->SetValue('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->SetValue('Verbraucherstatus', json_encode([
'Betriebsart' => $betriebsart,
'Restabweichung_W' => $ergebnis['Restabweichung_W'],
'Verbraucher' => $verbraucher,
], JSON_THROW_ON_ERROR));
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
continue;
}
try {
$this->sendeSollwert((int) $instanzID, $sollleistung);
$letzteSollwerte[(string) $instanzID] = $sollleistung;
} catch (Throwable $fehler) {
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
}
}
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
$this->SetStatus(self::STATUS_AKTIV);
}
/** @return array{0: float, 1: bool} */
private function leseNetzleistung(): array
{
$variablenID = $this->ReadPropertyInteger('NetzleistungVariableID');
if ($variablenID <= 0 || !IPS_VariableExists($variablenID)) {
return [0.0, false];
}
$variable = IPS_GetVariable($variablenID);
if (time() - (int) $variable['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
return [0.0, false];
}
$wert = GetValue($variablenID);
if (!is_int($wert) && !is_float($wert)) {
return [0.0, false];
}
return [(float) $wert * $this->ReadPropertyFloat('Netzleistungsfaktor'), true];
}
/**
* @param list<string> $stoerungen
* @return list<array{InstanzID: int, Daten: array<string, mixed>}>
*/
private function aktuelleVerbraucher(array &$stoerungen): array
{
$ids = $this->aktiveVerbraucherIDs();
$this->SetValue('VerbraucherAnzahl', count($ids));
$cache = $this->leseJsonAttribut('VerbraucherCache');
$timeout = $this->ReadPropertyInteger('VerbraucherTimeout');
$aktuell = [];
foreach ($ids as $instanzID) {
$eintrag = $cache[(string) $instanzID] ?? null;
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
$stoerungen[] = 'Noch keine Meldung von Instanz ' . $instanzID . '.';
continue;
}
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$stoerungen[] = 'Meldung von Instanz ' . $instanzID . ' ist veraltet.';
continue;
}
$aktuell[] = ['InstanzID' => $instanzID, 'Daten' => $eintrag['Daten']];
}
$verfuegbar = array_filter($aktuell, static fn (array $eintrag): bool => $eintrag['Daten']['Verfuegbar']);
$this->SetValue('VerbraucherVerfuegbar', count($verfuegbar));
return $aktuell;
}
/** @return list<int> */
private function aktiveVerbraucherIDs(): array
{
$zuordnung = json_decode($this->ReadPropertyString('VerbraucherZuordnung'), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($zuordnung)) {
throw new InvalidArgumentException('VerbraucherZuordnung muss eine JSON-Liste sein.');
}
$ids = [];
foreach ($zuordnung as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException('Jede Verbraucherzuordnung muss ein Objekt sein.');
}
$instanzID = $eintrag['InstanzID'] ?? $eintrag['Verbraucher'] ?? null;
if (!is_int($instanzID) || $instanzID <= 0 || !is_bool($eintrag['Aktiv'] ?? null)) {
throw new InvalidArgumentException('Verbraucherzuordnung benoetigt InstanzID und Aktiv.');
}
if ($eintrag['Aktiv']) {
$ids[] = $instanzID;
}
}
return array_values(array_unique($ids));
}
private function sendeSollwert(int $instanzID, int $sollleistung): void
{
if (!IPS_InstanceExists($instanzID)) {
throw new RuntimeException('Instanz existiert nicht.');
}
$paket = [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $this->InstanceID,
'EmpfaengerID' => $instanzID,
'Zeitpunkt' => time(),
],
'Sollleistung_W' => $sollleistung,
];
Nachrichtenvertrag::pruefeManagerdaten($paket);
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
$this->protokolliere('Sollwert', ['InstanzID' => $instanzID, 'Sollleistung_W' => $sollleistung]);
}
/**
* @param list<array{InstanzID: int, Daten: array<string, mixed>}> $verbraucher
* @param list<string> $stoerungen
*/
private function aktualisiereStoerungen(array $verbraucher, array $stoerungen): void
{
foreach ($verbraucher as $eintrag) {
foreach ($eintrag['Daten']['Zustand'] as $zustand) {
if ($zustand['Art'] === 'Stoerung' && $zustand['Wert'] === true) {
$stoerungen[] = 'Instanz ' . $eintrag['InstanzID'] . ': '
. ($zustand['Text'] ?? $zustand['Kennung']);
}
}
}
$stoerungen = array_values(array_unique($stoerungen));
$this->SetValue('Sammelstoerung', $stoerungen !== []);
$this->SetValue('Stoertext', implode("\n", $stoerungen));
}
private function registriereNetzleistungsmeldung(): void
{
$alt = $this->ReadAttributeInteger('RegistrierteNetzleistungVariableID');
$neu = $this->ReadPropertyInteger('NetzleistungVariableID');
if ($alt > 0 && $alt !== $neu) {
$this->UnregisterMessage($alt, self::VM_UPDATE);
}
if ($neu > 0 && $neu !== $alt) {
$this->RegisterMessage($neu, self::VM_UPDATE);
}
$this->WriteAttributeInteger('RegistrierteNetzleistungVariableID', $neu);
}
private function aktualisiereSichtbarkeit(): void
{
$logging = $this->ReadPropertyBoolean('LoggingEin');
foreach ([
'NetzleistungGueltig', 'WirksameLastspitzengrenze', 'Verteilbudget',
'VerbraucherAnzahl', 'VerbraucherVerfuegbar', 'Verbraucherstatus',
'Prognosestatus', 'SDLStatus', 'Lizenzstatus',
'Stoerueberwachungsstatus', 'Sammelstoerung', 'Stoertext',
] as $ident) {
IPS_SetHidden($this->GetIDForIdent($ident), !$logging);
}
$monatsgrenzenBedienbar = $logging && $this->ReadPropertyBoolean('MonatsgrenzenVisuBedienbar');
IPS_SetHidden($this->GetIDForIdent('Monatsgrenzen'), !$monatsgrenzenBedienbar);
if ($monatsgrenzenBedienbar) {
$this->EnableAction('Monatsgrenzen');
} else {
$this->DisableAction('Monatsgrenzen');
}
}
private function aktualisiereAnschlussstatus(): void
{
foreach ([
'PrognoseAnschluss' => 'Prognosestatus',
'SDLAnschluss' => 'SDLStatus',
'LizenzAnschluss' => 'Lizenzstatus',
'StoermeldeAnschluss' => 'Stoerueberwachungsstatus',
] as $property => $variable) {
$konfiguration = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
$aktiv = is_array($konfiguration) && ($konfiguration['Aktiv'] ?? false) === true;
$this->SetValue($variable, $aktiv ? 'Fehler' : 'NichtVerwendet');
}
}
private function pruefeKonfiguration(): void
{
foreach (['MesswertMaxAlter', 'Regelintervall', 'Vorgabeintervall', 'VerbraucherTimeout'] as $property) {
if ($this->ReadPropertyInteger($property) <= 0) {
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
}
}
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.');
}
$this->aktiveVerbraucherIDs();
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'LizenzAnschluss', 'StoermeldeAnschluss'] as $property) {
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($wert)) {
throw new InvalidArgumentException($property . ' muss ein JSON-Objekt sein.');
}
}
}
/** @return array<int, float> */
private function dekodiereMonatsgrenzen(string $json): array
{
$werte = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($werte)) {
throw new InvalidArgumentException('Monatsgrenzen muss ein JSON-Objekt sein.');
}
$ergebnis = [];
for ($monat = 1; $monat <= 12; ++$monat) {
$wert = $werte[(string) $monat] ?? $werte[$monat] ?? null;
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException('Monatsgrenzen muss fuer jeden Monat einen Zahlenwert enthalten.');
}
$ergebnis[$monat] = (float) $wert;
}
return $ergebnis;
}
/** @return array<string, mixed> */
private function leseJsonAttribut(string $name): array
{
$wert = json_decode($this->ReadAttributeString($name), true, 512, JSON_THROW_ON_ERROR);
return is_array($wert) ? $wert : [];
}
private function standardMonatsgrenzen(): string
{
return json_encode(array_fill_keys(range(1, 12), 0), JSON_THROW_ON_ERROR);
}
/** @param mixed $daten */
private function protokolliere(string $bezeichnung, $daten): void
{
if ($this->ReadPropertyBoolean('LoggingEin')) {
$this->SendDebug($bezeichnung, is_string($daten) ? $daten : json_encode($daten), 0);
}
}
}
+5 -5
View File
@@ -4,14 +4,13 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
## Status
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag und
die Deklaration der Verbraucherbasis sind implementiert. Die Module sind als
Diskussionsentwürfe dokumentiert, aber noch nicht als installierbare
IP-Symcon-Module angelegt.
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
Deklaration der Verbraucherbasis und das Manager-Modul sind implementiert. Die
weiteren Module sind als Diskussionsentwürfe dokumentiert.
## Geplante Module
- Manager
- Manager (implementiert)
- Batterie
- Wassererwaermer
- Pufferspeicher
@@ -50,5 +49,6 @@ Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingeche
## Dokumentation
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Manager-Modul](docs/module/Manager/README.md)
- [Obere Anschlüsse des Managers](docs/Obere-Anschluesse.md)
- [Modulübersicht](docs/module/README.md)
+36 -1
View File
@@ -1,6 +1,6 @@
# Manager
> Status: Diskussionsentwurf. Führt Hauptmanager und Peakshaving zusammen.
> Status: Implementiert. Führt Hauptmanager und Peakshaving zusammen.
Der Manager liest die Netzleistung, verwaltet ausschliesslich die ausgewählten
Verbraucher und verteilt Leistung im PV- oder Peak-Betrieb. Die Prioritäten
@@ -59,6 +59,41 @@ Ohne Logging sind nur `Aktiv`, `Betriebsart` und `Netzleistung` sichtbar.
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
## Verteilalgorithmus
Der Manager bildet das Verteilbudget aus der aktuellen Netzleistung, dem Ziel
der aktiven Betriebsart und der aktuellen Leistung aller frisch gemeldeten
Verbraucher. Eine gültige Istleistung wird bevorzugt; fehlt sie, bleibt der im
Zustand bestätigte Sollwert konservativ reserviert.
Nicht verfügbare, nicht änderbare oder aktuell angebotlose Verbraucher behalten
ihre Leistung und erhalten keine neue Vorgabe. Die übrigen Verbraucher werden
nach der gemeldeten PV- beziehungsweise Peak-Priorität und danach stabil nach
Instanz-ID sortiert. Von ihrem jeweils kleinsten erlaubten Leistungswert aus
wird das Budget in dieser Reihenfolge aufgefüllt. Einzelwerte und ganzzahlige
Bereiche werden direkt verarbeitet; Lücken werden nie durch unzulässige Werte
geschlossen. Eine verbleibende Abweichung wird im `Verbraucherstatus`
dokumentiert.
Die Betriebsart wechselt unterhalb der Überschussschwelle zu `PV` und oberhalb
der Lastspitzenschwelle zu `Peak`. Zwischen den Schwellen bleibt sie erhalten.
`Umschaltdifferenz` verhindert zusätzlich einen Wechsel, wenn sich die beiden
berechneten Korrekturen nicht ausreichend unterscheiden.
## Laufzeit und Fehlerverhalten
- Netzleistungsänderungen und der Regel-Timer lösen die Berechnung aus.
- Identische Sollwerte werden nur beim Vorgabeintervall erneut gesendet.
- Verbraucherpakete werden zentral geprüft und nur von aktiv zugeordneten
Absendern angenommen.
- Veraltete oder noch fehlende Verbrauchermeldungen werden nicht verteilt und
als Sammelstörung ausgewiesen.
- Bei fehlender oder veralteter Netzleistung bleibt die Regelung `Inaktiv` und
sendet keine neuen Vorgaben.
- Die oberen Anschlüsse bleiben optional. Solange kein konkreter Adapter
implementiert ist, meldet ein aktivierter Anschluss den Status `Fehler`, ohne
die lokale EMS-Regelung zu blockieren.
## Schnittstellen
- Empfängt genau `VerbraucherdatenEmpfangen(array $daten)`.
+4 -4
View File
@@ -1,7 +1,7 @@
# EMS-Modulentwürfe
# EMS-Module und Modulentwürfe
> Diese Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren
> IP-Symcon-Module.
> Der Manager ist als installierbares IP-Symcon-Modul umgesetzt. Die weiteren
> Ordner enthalten Besprechungsgrundlagen und noch keine installierbaren Module.
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
@@ -9,7 +9,7 @@ nur zusätzliche Properties, Variablen, Zustände und offene Punkte.
| Modul | Rolle |
| --- | --- |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse |
| [Manager](Manager/README.md) | Leistungsverteilung und Anschlüsse (implementiert) |
| [Batterie](Batterie/README.md) | Laden und Entladen eines Speichers |
| [Wassererwärmer](Wassererwaermer/README.md) | Mehrstufige elektrische Erwärmung |
| [Pufferspeicher](Pufferspeicher/README.md) | Heizstufen nach Heizkurve |
+252
View File
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/**
* Reine Regel- und Verteilfunktion des Managers.
*
* Die Klasse kennt keine IP-Symcon-Funktionen und kann deshalb unabhaengig
* vom Laufzeitsystem getestet werden. Alle Leistungen verwenden die
* Vorzeichenkonvention des Vertrags: positiv Bezug, negativ Abgabe.
*/
final class ManagerRegler
{
public const BETRIEBSART_INAKTIV = 'Inaktiv';
public const BETRIEBSART_PV = 'PV';
public const BETRIEBSART_PEAK = 'Peak';
/**
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
*/
public static function wirksameLastspitzengrenze(
int $modus,
float $konstanteGrenze,
array $monatsgrenzen,
int $monat
): ?float {
if ($modus === 0) {
return null;
}
if ($modus === 1) {
return $konstanteGrenze;
}
if ($modus !== 2) {
throw new InvalidArgumentException('Lastspitzenmodus muss 0, 1 oder 2 sein.');
}
if ($monat < 1 || $monat > 12 || !array_key_exists($monat, $monatsgrenzen)) {
throw new InvalidArgumentException('Fuer den aktuellen Monat fehlt eine Lastspitzengrenze.');
}
return (float) $monatsgrenzen[$monat];
}
public static function bestimmeBetriebsart(
bool $aktiv,
bool $netzleistungGueltig,
float $netzleistung,
string $bisherigeBetriebsart,
bool $peakAktiv,
float $umschaltschwelleUeberschuss,
float $umschaltschwelleLastspitze,
float $sollwertSolarladen,
float $wirksameLastspitzengrenze,
float $umschaltdifferenzProzent
): string {
if (!$aktiv || !$netzleistungGueltig) {
return self::BETRIEBSART_INAKTIV;
}
if (!$peakAktiv) {
return self::BETRIEBSART_PV;
}
if ($umschaltdifferenzProzent < 0 || $umschaltdifferenzProzent > 100) {
throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 Prozent liegen.');
}
$kandidat = $bisherigeBetriebsart;
if ($netzleistung < $umschaltschwelleUeberschuss) {
$kandidat = self::BETRIEBSART_PV;
} elseif ($netzleistung > $umschaltschwelleLastspitze) {
$kandidat = self::BETRIEBSART_PEAK;
} elseif (!in_array($kandidat, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)) {
$kandidat = self::BETRIEBSART_PV;
}
if ($kandidat === $bisherigeBetriebsart
|| !in_array($bisherigeBetriebsart, [self::BETRIEBSART_PV, self::BETRIEBSART_PEAK], true)
|| $umschaltdifferenzProzent === 0.0
) {
return $kandidat;
}
$pvKorrektur = $sollwertSolarladen - $netzleistung;
$peakKorrektur = $wirksameLastspitzengrenze - $netzleistung;
$bezugswert = max(abs($pvKorrektur), abs($peakKorrektur), 1.0);
$differenzProzent = abs($pvKorrektur - $peakKorrektur) / $bezugswert * 100.0;
return $differenzProzent >= $umschaltdifferenzProzent
? $kandidat
: $bisherigeBetriebsart;
}
/**
* @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten
* @return array{
* Verteilbudget_W: int,
* AktuelleVerbraucherleistung_W: int,
* NichtSteuerbar_W: int,
* Sollwerte: array<int, int>,
* Restabweichung_W: int
* }
*/
public static function verteile(
float $netzleistung,
float $zielnetzleistung,
array $verbraucher,
string $prioritaetsfeld
): array {
if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) {
throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.');
}
$aktuelleLeistung = 0;
$nichtSteuerbar = 0;
$steuerbar = [];
foreach ($verbraucher as $eintrag) {
self::pruefeVerteilEintrag($eintrag, $prioritaetsfeld);
$leistung = self::aktuelleLeistung($eintrag['Daten']);
$aktuelleLeistung += $leistung;
$daten = $eintrag['Daten'];
if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) {
$nichtSteuerbar += $leistung;
continue;
}
$steuerbar[] = [
'InstanzID' => $eintrag['InstanzID'],
'Prioritaet' => $daten[$prioritaetsfeld],
'Leistungswerte_W' => $daten['Leistungswerte_W'],
];
}
usort($steuerbar, static function (array $links, array $rechts): int {
return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']];
});
$verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung);
$sollwerte = [];
$minimumSumme = 0;
foreach ($steuerbar as $eintrag) {
$minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']);
$sollwerte[$eintrag['InstanzID']] = $minimum;
$minimumSumme += $minimum;
}
$verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme);
foreach ($steuerbar as $eintrag) {
$instanzID = $eintrag['InstanzID'];
$minimum = $sollwerte[$instanzID];
$neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$minimum + $verfuegbar
);
$verfuegbar -= $neuerSollwert - $minimum;
$sollwerte[$instanzID] = $neuerSollwert;
}
$zugeteilt = $nichtSteuerbar + array_sum($sollwerte);
return [
'Verteilbudget_W' => $verteilbudget,
'AktuelleVerbraucherleistung_W' => $aktuelleLeistung,
'NichtSteuerbar_W' => $nichtSteuerbar,
'Sollwerte' => $sollwerte,
'Restabweichung_W' => $verteilbudget - $zugeteilt,
];
}
/**
* @param array<string, mixed> $daten
*/
public static function angenommenerSollwert(array $daten): ?int
{
foreach ($daten['Zustand'] as $zustand) {
if ($zustand['Kennung'] === 'Sollleistung_W') {
return is_int($zustand['Wert']) ? $zustand['Wert'] : null;
}
}
return null;
}
/**
* @param array<string, mixed> $daten
*/
private static function aktuelleLeistung(array $daten): int
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (int) round((float) $daten['Istleistung_W']);
}
return self::angenommenerSollwert($daten) ?? 0;
}
/**
* @param array<string, mixed> $eintrag
*/
private static function pruefeVerteilEintrag(array $eintrag, string $prioritaetsfeld): void
{
if (!isset($eintrag['InstanzID'], $eintrag['Daten'])
|| !is_int($eintrag['InstanzID'])
|| $eintrag['InstanzID'] <= 0
|| !is_array($eintrag['Daten'])
) {
throw new InvalidArgumentException('Verbrauchereintrag ist ungueltig.');
}
Nachrichtenvertrag::pruefeVerbraucherdaten($eintrag['Daten']);
if (!array_key_exists($prioritaetsfeld, $eintrag['Daten'])) {
throw new InvalidArgumentException('Prioritaet fehlt im Verbrauchereintrag.');
}
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function kleinsterLeistungswert(array $leistungswerte): int
{
$erster = $leistungswerte[0];
return is_int($erster) ? $erster : $erster['Von_W'];
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
private static function groessterLeistungswertBis(array $leistungswerte, int $obergrenze): int
{
$gewaehlt = self::kleinsterLeistungswert($leistungswerte);
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag > $obergrenze) {
break;
}
$gewaehlt = $eintrag;
continue;
}
if ($eintrag['Von_W'] > $obergrenze) {
break;
}
$gewaehlt = min($eintrag['Bis_W'], $obergrenze);
}
return $gewaehlt;
}
}
+8 -1
View File
@@ -12,7 +12,6 @@ final class DokumentationsstrukturTest extends TestCase
public function modulReadmes(): array
{
return [
'Manager',
'Batterie',
'Wassererwaermer',
'Pufferspeicher',
@@ -42,4 +41,12 @@ final class DokumentationsstrukturTest extends TestCase
self::assertFileExists(__DIR__ . '/../docs/Obere-Anschluesse.md');
self::assertFileExists(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
}
public function testManagerDokumentiertDenImplementiertenStand(): void
{
$inhalt = file_get_contents(__DIR__ . '/../docs/module/Manager/README.md');
self::assertNotFalse($inhalt);
self::assertStringContainsString('Status: Implementiert', $inhalt);
self::assertStringContainsString('Verteilalgorithmus', $inhalt);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class ManagerModulstrukturTest extends TestCase
{
public function testManagerIstAlsSymconModulVollstaendig(): void
{
foreach (['module.json', 'form.json', 'module.php'] as $datei) {
self::assertFileExists(__DIR__ . '/../Manager/' . $datei);
}
$metadaten = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/module.json'),
true,
512,
JSON_THROW_ON_ERROR
);
self::assertSame('Manager', $metadaten['name']);
self::assertSame(3, $metadaten['type']);
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
}
public function testFormularEnthaeltDieZentralenManagerProperties(): void
{
$formular = json_decode(
(string) file_get_contents(__DIR__ . '/../Manager/form.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$json = json_encode($formular, JSON_THROW_ON_ERROR);
foreach ([
'NetzleistungVariableID',
'VerbraucherZuordnung',
'Lastspitzenmodus',
'Monatsgrenzen',
'SollwertSolarladen',
'Umschaltdifferenz',
'LoggingEin',
] as $property) {
self::assertStringContainsString($property, $json);
}
}
public function testManagerImplementiertDenGemeinsamenEmpfangsvertrag(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\Nachrichtenvertrag;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class ManagerReglerTest extends TestCase
{
public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
25000.0,
ManagerRegler::BETRIEBSART_INAKTIV,
false,
-100.0,
10000.0,
0.0,
15000.0,
0.0
)
);
}
public function testBetriebsartBleibtZwischenDenSchwellenErhalten(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PEAK,
ManagerRegler::bestimmeBetriebsart(
true,
true,
5000.0,
ManagerRegler::BETRIEBSART_PEAK,
true,
0.0,
10000.0,
0.0,
12000.0,
0.0
)
);
}
public function testUmschaltdifferenzVerhindertZuKleineAenderung(): void
{
self::assertSame(
ManagerRegler::BETRIEBSART_PV,
ManagerRegler::bestimmeBetriebsart(
true,
true,
11000.0,
ManagerRegler::BETRIEBSART_PV,
true,
0.0,
10000.0,
10000.0,
10500.0,
60.0
)
);
}
public function testVerteilungBeruecksichtigtPrioritaetEinzelwerteUndBereiche(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
4500.0,
[
$this->verbraucher(20, 5, [0, 3000], 0),
$this->verbraucher(10, 0, [0, ['Von_W' => 1000, 'Bis_W' => 2000], 4000], 0),
],
'PrioritaetPV'
);
self::assertSame(4500, $ergebnis['Verteilbudget_W']);
self::assertSame([10 => 4000, 20 => 0], $ergebnis['Sollwerte']);
self::assertSame(500, $ergebnis['Restabweichung_W']);
}
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void
{
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false);
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 0, 'Bis_W' => 4000]], 0);
$ergebnis = ManagerRegler::verteile(3000.0, 5000.0, [$fest, $steuerbar], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['NichtSteuerbar_W']);
self::assertSame([20 => 2000], $ergebnis['Sollwerte']);
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void
{
$verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false);
$verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN;
$verbraucher['Daten']['Istleistung_W'] = null;
$verbraucher['Daten']['Zustand'][0]['Wert'] = 3000;
$ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV');
self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']);
self::assertSame(-3000, $ergebnis['Restabweichung_W']);
}
public function testMonatlicheGrenzeMussVollstaendigSein(): void
{
$this->expectException(InvalidArgumentException::class);
ManagerRegler::wirksameLastspitzengrenze(2, 0.0, [1 => 12000.0], 9);
}
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
* @return array{InstanzID: int, Daten: array<string, mixed>}
*/
private function verbraucher(
int $instanzID,
int $prioritaet,
array $leistungswerte,
?int $istleistung,
bool $aenderungMoeglich = true
): array {
$quelle = $istleistung === null
? Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
: Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN;
return [
'InstanzID' => $instanzID,
'Daten' => [
'Kopf' => [
'Version' => Nachrichtenvertrag::VERSION,
'AbsenderID' => $instanzID,
'EmpfaengerID' => 1,
'Zeitpunkt' => 1788825600,
],
'PrioritaetPV' => $prioritaet,
'PrioritaetPeak' => $prioritaet,
'Leistungswerte_W' => $leistungswerte,
'AenderungMoeglich' => $aenderungMoeglich,
'Verfuegbar' => true,
'Istleistung_W' => $istleistung,
'Leistungsquelle' => $quelle,
'Zustand' => [[
'Kennung' => 'Sollleistung_W',
'Art' => 'Sollwert',
'Wert' => $istleistung,
'Einheit' => 'W',
]],
],
];
}
}