Installierbares IP-Symcon-Manager-Modul mit testbarer Verteilungslogik, Verbraucher-Timeouts, Sollwertversand und aktualisierter Dokumentation.
This commit is contained in:
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user