Compare commits
58
Commits
7845c66fd8
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5437f3f058 | ||
|
|
ebec355c96 | ||
|
|
58f91e4319 | ||
|
|
ec7182b833 | ||
|
|
37addf4242 | ||
|
|
8d83b083bf | ||
|
|
6cd7bf19f5 | ||
|
|
3ce9af4244 | ||
|
|
8357e8dfab | ||
|
|
38353aed93 | ||
|
|
cf92e7623b | ||
|
|
add1077674 | ||
|
|
bfdcf4536b | ||
|
|
faa5dfb33f | ||
|
|
53fca5e2bb | ||
|
|
f611da975d | ||
|
|
393969594e | ||
|
|
cae14cf399 | ||
|
|
3aaa744a8a | ||
|
|
fdc2007bb1 | ||
|
|
37400878b6 | ||
|
|
ace1821213 | ||
|
|
b3b868cd2a | ||
|
|
a74f14aa19 | ||
|
|
fe966516b5 | ||
|
|
b3bbae1a72 | ||
|
|
ae2cd8ecff | ||
|
|
995b704e36 | ||
|
|
5ff0d405d4 | ||
|
|
ccecc437e9 | ||
|
|
156591a91e | ||
|
|
e0814a0c14 | ||
|
|
3cabf597ac | ||
|
|
b86810d30f | ||
|
|
d1fc62d43c | ||
|
|
21577c0125 | ||
|
|
aefe46352f | ||
|
|
6f76e6cd86 | ||
|
|
18c34b32e7 | ||
|
|
b111a5c501 | ||
|
|
ef708564d8 | ||
|
|
cf2ce3aff4 | ||
|
|
708a8ecea2 | ||
|
|
40f41a2fb8 | ||
|
|
2d24ce0900 | ||
|
|
f47c908798 | ||
|
|
72d96aeb81 | ||
|
|
dcf51bd8be | ||
|
|
efa8fe698c | ||
|
|
f250dea59a | ||
|
|
1e6481330a | ||
|
|
dbd728dd03 | ||
|
|
a67018e68f | ||
|
|
0f1e0c9fbb | ||
|
|
693beb2263 | ||
|
|
b1cb289922 | ||
|
|
98ee569178 | ||
|
|
2392a9c209 |
@@ -21,6 +21,12 @@ jobs:
|
||||
- name: Abhaengigkeiten installieren
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Composer-Metadaten pruefen
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Abhaengigkeiten auf Sicherheitsmeldungen pruefen
|
||||
run: composer audit --no-interaction
|
||||
|
||||
- name: Unit-Tests und Syntaxpruefung ausfuehren
|
||||
run: composer check
|
||||
|
||||
|
||||
+16
-7
@@ -8,7 +8,7 @@ IP-Symcon-Registervariablen.
|
||||
## Funktionen
|
||||
|
||||
- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen
|
||||
- 250-W-Grundraster mit 50-W-Feinwerten zwischen -200 W und +200 W
|
||||
- stufenlose Leistungsbereiche in ganzen Watt innerhalb der dynamischen Grenzen
|
||||
- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb
|
||||
- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese
|
||||
- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe,
|
||||
@@ -82,17 +82,26 @@ Messwert, ist er veraltet oder liegt der Ladezustand ausserhalb von 0 bis
|
||||
ReserveLadezustand entspricht der bisherigen Peakshaving-Reserve.
|
||||
MindestLadezustand schuetzt vor weiterer Entladung.
|
||||
LadezustandHysterese ersetzt die bisher fest im Code hinterlegte
|
||||
2-Prozent-Hysterese.
|
||||
2-Prozent-Hysterese. NachladenMitMaximalleistung verwendet standardmaessig die
|
||||
dynamische maximale Ladeleistung. Wird der Schalter deaktiviert, begrenzt
|
||||
MaximaleNachladeleistung das schutzbedingte Nachladen in W. Der wirksame Wert
|
||||
wird nie hoeher als die dynamische Ladegrenze der Batterie.
|
||||
|
||||
Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten:
|
||||
|
||||
- Im PV-Betrieb steht oberhalb der Reserve das gesamte Raster zur Verfuegung.
|
||||
- Unterhalb der Reserve wird die maximale Ladeleistung angeboten.
|
||||
- Bei vollem Speicher werden nur Entladung und 0 angeboten.
|
||||
- Im PV-Betrieb steht oberhalb der Reserve der durchgaengige Bereich von der
|
||||
maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
|
||||
- Unterhalb der Reserve wird die wirksame Nachladeleistung als fester Wert
|
||||
angeboten.
|
||||
- Bei vollem Speicher wird der durchgaengige Bereich von der maximalen
|
||||
Entladeleistung bis 0 W angeboten.
|
||||
- Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller
|
||||
Batterieleistung berechnete Entladewert angeboten.
|
||||
- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden
|
||||
Laden und Entladen wie bisher eingeschraenkt.
|
||||
Laden und Entladen wie bisher eingeschraenkt; die positive Grenze ist dabei
|
||||
hoechstens die konfigurierte Nachladeleistung.
|
||||
- Oberhalb der Reserve ohne aktive Hysterese bleibt der normale Ladebereich
|
||||
unveraendert und wird nicht durch die Nachladegrenze reduziert.
|
||||
|
||||
## Manager und Zeitverhalten
|
||||
|
||||
@@ -131,7 +140,7 @@ verwirft die aktuelle Vorgabe und schreibt eine Leistung von 0.
|
||||
1. Batterietyp und Steuerung auswaehlen.
|
||||
2. Alle fuenf Messwertvariablen zuordnen.
|
||||
3. Die automatisch eingeblendeten Registervariablen zuordnen.
|
||||
4. Reserve, Mindestladezustand und Hysterese pruefen.
|
||||
4. Reserve, Mindestladezustand, Hysterese und Nachladeleistung pruefen.
|
||||
5. Diagnosevariablen und Logging fuer die Erstinbetriebnahme aktivieren.
|
||||
6. Die Batterie im Manager zuordnen.
|
||||
7. Aktiv einschalten.
|
||||
|
||||
@@ -137,6 +137,20 @@
|
||||
"minimum": 0,
|
||||
"maximum": 100,
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "NachladenMitMaximalleistung",
|
||||
"caption": "Beim Nachladen maximale Ladeleistung verwenden",
|
||||
"onChange": "IPS_RequestAction($id, 'FormNachladenMitMaximalleistung', $NachladenMitMaximalleistung);"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "MaximaleNachladeleistung",
|
||||
"caption": "Maximale Nachladeleistung",
|
||||
"suffix": " W",
|
||||
"minimum": 1,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+64
-17
@@ -86,6 +86,8 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->RegisterPropertyFloat('ReserveLadezustand', 20.0);
|
||||
$this->RegisterPropertyFloat('MindestLadezustand', 10.0);
|
||||
$this->RegisterPropertyFloat('LadezustandHysterese', 2.0);
|
||||
$this->RegisterPropertyBoolean('NachladenMitMaximalleistung', true);
|
||||
$this->RegisterPropertyInteger('MaximaleNachladeleistung', 0);
|
||||
$this->RegisterPropertyInteger('MesswertMaxAlter', 60);
|
||||
$this->RegisterPropertyInteger('Aenderungssperre', 4);
|
||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||
@@ -151,6 +153,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
$this->registriereMesswertmeldungen();
|
||||
$this->WriteAttributeBoolean('RegisterInitialisiert', false);
|
||||
|
||||
@@ -185,6 +188,11 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$formular['elements'],
|
||||
$this->ReadPropertyInteger('Batterietyp')
|
||||
);
|
||||
$this->setzeFormfeldSichtbar(
|
||||
$formular['elements'],
|
||||
'MaximaleNachladeleistung',
|
||||
!$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
||||
);
|
||||
|
||||
return json_encode($formular, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -212,7 +220,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
@@ -228,7 +236,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
|
||||
case 'VorgabeTimeout':
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -242,7 +250,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
return;
|
||||
|
||||
case 'SichererZustand':
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->schreibeRegister(0, true);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
@@ -251,6 +259,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->aktualisiereFormRegisterfelder((int) $wert);
|
||||
return;
|
||||
|
||||
case 'FormNachladenMitMaximalleistung':
|
||||
$this->UpdateFormField(
|
||||
'MaximaleNachladeleistung',
|
||||
'visible',
|
||||
!(bool) $wert
|
||||
);
|
||||
return;
|
||||
|
||||
case 'ManagerdatenEmpfangen':
|
||||
if (!is_string($wert)) {
|
||||
throw new InvalidArgumentException(
|
||||
@@ -293,20 +309,22 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$sollleistung = $daten['Sollleistung_W'];
|
||||
if ($sollleistung === null) {
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array(
|
||||
(int) $this->leseZustand('Sollleistung'),
|
||||
&& !Nachrichtenvertrag::enthaeltLeistungswert(
|
||||
$this->leseLeistungsangebot(),
|
||||
true
|
||||
(int) $this->leseZustand('Sollleistung')
|
||||
)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(false);
|
||||
}
|
||||
$this->planeMeldung();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) {
|
||||
if (!Nachrichtenvertrag::enthaeltLeistungswert(
|
||||
$this->leseLeistungsangebot(),
|
||||
$sollleistung
|
||||
)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
|
||||
);
|
||||
@@ -380,7 +398,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$messwerte['Netzleistung'],
|
||||
$messwerte['Istleistung'],
|
||||
$messwerte['MaxLaden'],
|
||||
$messwerte['MaxEntladen']
|
||||
$messwerte['MaxEntladen'],
|
||||
$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
||||
? null
|
||||
: (float) $this->ReadPropertyInteger('MaximaleNachladeleistung')
|
||||
)
|
||||
: [0];
|
||||
|
||||
@@ -394,13 +415,12 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
);
|
||||
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array(
|
||||
(int) $this->leseZustand('Sollleistung'),
|
||||
&& !Nachrichtenvertrag::enthaeltLeistungswert(
|
||||
$angebot,
|
||||
true
|
||||
(int) $this->leseZustand('Sollleistung')
|
||||
)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->pruefeVorgabeTimeout($jetzt);
|
||||
|
||||
@@ -577,7 +597,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
if ($letzteVorgabe <= 0
|
||||
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
}
|
||||
@@ -775,6 +795,14 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
||||
&& $this->ReadPropertyInteger('MaximaleNachladeleistung') <= 0
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
'MaximaleNachladeleistung muss groesser als 0 W sein.'
|
||||
);
|
||||
}
|
||||
|
||||
BatterieRegler::hystereseAktiv(
|
||||
50.0,
|
||||
$this->ReadPropertyFloat('ReserveLadezustand'),
|
||||
@@ -792,7 +820,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0
|
||||
1.0,
|
||||
$this->ReadPropertyBoolean('NachladenMitMaximalleistung')
|
||||
? null
|
||||
: (float) $this->ReadPropertyInteger('MaximaleNachladeleistung')
|
||||
);
|
||||
|
||||
foreach (self::MESSWERT_PROPERTIES as $property) {
|
||||
@@ -1073,7 +1104,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
/** @return list<int|array{Von_W: int, Bis_W: int}> */
|
||||
private function leseLeistungsangebot(): array
|
||||
{
|
||||
$angebot = json_decode(
|
||||
@@ -1086,7 +1117,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
return [0];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', $angebot));
|
||||
return array_values($angebot);
|
||||
}
|
||||
|
||||
private function setzeStoerung(string $text): void
|
||||
@@ -1095,6 +1126,22 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->setzeZustand('Stoertext', $text);
|
||||
}
|
||||
|
||||
/** @param array<int, mixed> $elemente */
|
||||
private function setzeFormfeldSichtbar(
|
||||
array &$elemente,
|
||||
string $name,
|
||||
bool $sichtbar
|
||||
): void {
|
||||
foreach ($elemente as &$element) {
|
||||
if (($element['name'] ?? '') === $name) {
|
||||
$element['visible'] = $sichtbar;
|
||||
}
|
||||
if (isset($element['items']) && is_array($element['items'])) {
|
||||
$this->setzeFormfeldSichtbar($element['items'], $name, $sichtbar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, mixed> $elemente */
|
||||
private function setzeRegisterfelderSichtbar(
|
||||
array &$elemente,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Verbraucher: Beim Verwerfen einer Manager-Vorgabe wird auch die gespeicherte
|
||||
und sichtbare Sollleistung auf 0 W gesetzt. Gilt fuer Batterie, beide
|
||||
Ladestationen, Pufferspeicher, Verbraucher 1-Stufig, Waermepumpe und
|
||||
Warmwassererwaermer. Bereits ungueltige Altwerte werden beim Anwenden der
|
||||
Instanzkonfiguration bereinigt. Gueltige Vorgaben, lokale Schutzprogramme,
|
||||
Mindestlaufzeiten und der Nachrichtenvertrag bleiben erhalten.
|
||||
|
||||
- Manager: SDL-/Regelenergie als separate Istleistungsquelle mit optionalem SOC,
|
||||
Leistungsfaktor und getrennten Anzeigeoptionen fuer Energiefluss und Diagramme.
|
||||
SDL wird unabhaengig von der Anzeige aus Hauslast und neuer Hausenergie
|
||||
herausgerechnet; die Prognosetelemetrie verwendet die korrigierte Hauslast.
|
||||
Bestehende Visualisierungen werden ausschliesslich um eigene SDL-Eintraege
|
||||
ergaenzt. Standardmaessig deaktiviert, keine automatische Quellenmigration,
|
||||
keine rueckwirkende Datenkorrektur und keine Aenderung externer SDL-Steuerungen.
|
||||
- Manager: Positives Budget wird bei gleicher Prioritaet nach der kleinsten
|
||||
naechsten erreichbaren Sollleistung verteilt. Nur bei gleicher Leistungsstufe
|
||||
entscheiden exakter Energiebezug und anschliessend die Instanz-ID. Einzelstufen
|
||||
und ganzzahlige Bereiche teilen sich das Budget; reservierte Lasten und die
|
||||
negative Defizitregelung bleiben unveraendert. Keine Konfigurationsmigration.
|
||||
|
||||
## 0.1 Build 1 - Erste Beta - 2026-09-29
|
||||
|
||||
### Enthalten
|
||||
|
||||
- Enelix Manager mit PV- und Peak-Betrieb, Priorisierung und Diagnose.
|
||||
- Anlagenweite Einspeisebegrenzung, Netzfahrplan und Prognosetopologie.
|
||||
- Batterie, Warmwassererwaermer, Pufferspeicher und Waermepumpe.
|
||||
- Verbraucher 1-Stufig sowie Easee Gateway und beide Ladestationsvarianten.
|
||||
- Lizenzpruefung fuer Manager und einzeln freischaltbare Verbrauchermodule.
|
||||
- Idempotente Demoanlage fuer Installation und Integrationstests.
|
||||
|
||||
### Beta-Aenderungen
|
||||
|
||||
- Ladestationen halten beim Aktivieren und Umschalten von Solarladen den bisherigen Strom bis zur neuen Manager-Vorgabe; die Stand-alone-Phasenprobe verwendet nur noch 6 A.
|
||||
- Fahrzeugstatus ist bei beiden Ladestationsvarianten eine Diagnosevariable, Ist- und Sollleistung gehoeren zu den optionalen Visualisierungseinstellungen.
|
||||
- Stufenlose Leistungsbereiche fuer Batteriespeicher.
|
||||
- Schutzbedingtes Nachladen der Batterie kann wahlweise auf die dynamische Maximalleistung oder eine benutzerdefinierte Watt-Grenze begrenzt werden.
|
||||
- Ladefreigabe neuer Ladestationsinstanzen standardmaessig aktiviert.
|
||||
- Stand-alone-Ladestationen erkennen nach einer einstellbaren Beobachtungszeit den niedrigeren Fahrzeug-Maximalstrom und begrenzen ihr Leistungsangebot ohne Ladeunterbruch.
|
||||
- Easee-Ladestationen laden einen fehlenden Initialstatus ueber die Observations-API nach und starten bei noch unbekannter Ausgangsphase konservativ einphasig.
|
||||
- Wartende Easee-Ladestationen erhalten bei positiver Stromvorgabe einmal pro Fahrzeugverbindung einen Startbefehl; Freigabe- und Blockiergruende werden diagnostiziert.
|
||||
- Fehlende oder veraltete Verbraucher blockieren die Verteilung an aktuelle Verbraucher nicht mehr.
|
||||
- Verbraucher gleicher Prioritaet werden wie in Enelix 1 anhand ihrer bezogenen Energie in 2-kWh-Gruppen ausgeglichen.
|
||||
- Die Stoerungsueberwachung fordert einen fehlenden Geraetezugang innerhalb ihres Retry-Zyklus automatisch neu an.
|
||||
- Der Warmwassererwaermer verwendet konfigurierbare Temperatur-Stoergrenzen von standardmaessig 0 bis 100 Grad C.
|
||||
- Vollstaendige PHP-, Struktur- und Symcon-Modultests fuer alle Module.
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Diese Version ist fuer kontrollierte Anlagen- und Feldtests bestimmt.
|
||||
- Vor produktivem Einsatz sind Sicherung und anlagenspezifische Abnahme erforderlich.
|
||||
- Korrekturen werden vorwaerts ueber `develop` und anschliessend `beta` verteilt.
|
||||
+204
-31
@@ -16,6 +16,7 @@ class EaseeGateway extends IPSModule
|
||||
private const SIGNALR_BASE_URL = 'https://streams.easee.com/hubs/chargers';
|
||||
private const API_BASE_URL = 'https://api.easee.com';
|
||||
private const RECORD_SEPARATOR = "\x1e";
|
||||
private const CACHE_MAXIMALALTER_SEKUNDEN = 60;
|
||||
|
||||
public function Create(): void
|
||||
{
|
||||
@@ -50,6 +51,11 @@ class EaseeGateway extends IPSModule
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'RefreshToken', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'ReconnectTimer',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'ReconnectAfterLoss', false);"
|
||||
);
|
||||
|
||||
$this->RequireParent(self::WEBSOCKET_MODULE_ID);
|
||||
}
|
||||
@@ -60,6 +66,7 @@ class EaseeGateway extends IPSModule
|
||||
|
||||
$this->SetTimerInterval('MaintainConnectionTimer', 0);
|
||||
$this->SetTimerInterval('TokenRefreshTimer', 0);
|
||||
$this->SetTimerInterval('ReconnectTimer', 0);
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->SetBuffer('ReceiveBuffer', '');
|
||||
$this->SetBuffer('Subscriptions', '{}');
|
||||
@@ -111,8 +118,22 @@ class EaseeGateway extends IPSModule
|
||||
$this->pflegeVerbindung();
|
||||
return;
|
||||
|
||||
case 'ReconnectAfterLoss':
|
||||
$this->SetTimerInterval('ReconnectTimer', 0);
|
||||
if (
|
||||
$this->ReadPropertyBoolean('Active')
|
||||
&& !$this->ReadPropertyBoolean('Testmodus')
|
||||
&& $this->GetBuffer('SignalRReady') !== '1'
|
||||
) {
|
||||
$this->verbindeSignalR();
|
||||
}
|
||||
return;
|
||||
|
||||
case 'RefreshToken':
|
||||
if ($this->erneuereZugangsdaten(false)) {
|
||||
if (
|
||||
$this->erneuereZugangsdaten(false)
|
||||
&& $this->GetBuffer('SignalRReady') !== '1'
|
||||
) {
|
||||
$this->verbindeSignalR();
|
||||
}
|
||||
return;
|
||||
@@ -142,6 +163,15 @@ class EaseeGateway extends IPSModule
|
||||
$daten['value'] ?? null
|
||||
);
|
||||
return;
|
||||
|
||||
case 'TestSignalRFrame':
|
||||
if (!$this->ReadPropertyBoolean('Testmodus') || !is_string($wert)) {
|
||||
throw new InvalidArgumentException(
|
||||
'TestSignalRFrame ist nur im Testmodus zulaessig.'
|
||||
);
|
||||
}
|
||||
$this->verarbeiteSignalRRahmen($wert);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unbekannte Aktion: ' . $ident);
|
||||
@@ -221,6 +251,12 @@ class EaseeGateway extends IPSModule
|
||||
$seriennummer,
|
||||
(float) ($anfrage['amps'] ?? -1)
|
||||
), JSON_THROW_ON_ERROR);
|
||||
|
||||
case 'StartCharging':
|
||||
return json_encode(
|
||||
$this->sendeLadestart($seriennummer),
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
return json_encode([
|
||||
@@ -271,8 +307,10 @@ class EaseeGateway extends IPSModule
|
||||
if ($letzterEmpfang > 0 && ($jetzt - $letzterEmpfang) <= 90) {
|
||||
return;
|
||||
}
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->setzeVerbunden(false);
|
||||
$this->planeWiederverbindung(
|
||||
'Seit mehr als 90 Sekunden keine Easee-Ereignisdaten empfangen.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (($jetzt - $letzteAushandlung) >= 45) {
|
||||
@@ -285,16 +323,22 @@ class EaseeGateway extends IPSModule
|
||||
|
||||
private function verbindeSignalR(): bool
|
||||
{
|
||||
$this->SetTimerInterval('ReconnectTimer', 0);
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
$this->SetStatus(102);
|
||||
$this->setzeVerbunden(true);
|
||||
$this->setzeEreignisverbindung(true);
|
||||
return true;
|
||||
}
|
||||
if (!$this->stelleAccessTokenSicher()) {
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->setzeVerbunden(false);
|
||||
$this->SetStatus(202);
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->setzeEreignisverbindung(
|
||||
false,
|
||||
'Easee-Ereignisverbindung wird aufgebaut.'
|
||||
);
|
||||
$this->SetBuffer('LastNegotiation', (string) time());
|
||||
$token = $this->GetBuffer('AccessToken');
|
||||
$antwort = $this->httpAnfrage(
|
||||
@@ -313,11 +357,11 @@ class EaseeGateway extends IPSModule
|
||||
);
|
||||
}
|
||||
if (!$antwort['success']) {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler(
|
||||
$this->setzeEreignisverbindung(
|
||||
false,
|
||||
'Easee-Ereignisverbindung fehlgeschlagen: HTTP '
|
||||
. $antwort['httpCode']
|
||||
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
|
||||
. $antwort['httpCode']
|
||||
. ($antwort['error'] !== '' ? ' / ' . $antwort['error'] : '')
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -327,8 +371,10 @@ class EaseeGateway extends IPSModule
|
||||
? (string) ($daten['connectionToken'] ?? $daten['connectionId'] ?? '')
|
||||
: '';
|
||||
if ($verbindungstoken === '') {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler('Easee liefert kein Verbindungstoken.');
|
||||
$this->setzeEreignisverbindung(
|
||||
false,
|
||||
'Easee liefert kein Verbindungstoken.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -340,7 +386,7 @@ class EaseeGateway extends IPSModule
|
||||
$this->SetBuffer('ReceiveBuffer', '');
|
||||
$this->SetBuffer('SubscribedThisConnection', '{}');
|
||||
$this->SetBuffer('PendingInvocations', '{}');
|
||||
$this->WriteAttributeString('ObservationCache', '{}');
|
||||
$this->markiereBeobachtungscacheVeraltet();
|
||||
$this->setzeVerbunden(false);
|
||||
|
||||
if (!$this->konfiguriereWebSocketParent($url)) {
|
||||
@@ -403,10 +449,7 @@ class EaseeGateway extends IPSModule
|
||||
private function verarbeiteSignalRRahmen(string $rahmen): void
|
||||
{
|
||||
if ($rahmen === '{}') {
|
||||
$this->SetBuffer('SignalRReady', '1');
|
||||
$this->SetStatus(102);
|
||||
$this->setzeLetztenFehler('');
|
||||
$this->setzeVerbunden(true);
|
||||
$this->setzeEreignisverbindung(true);
|
||||
$this->sendeAlleAbonnements();
|
||||
return;
|
||||
}
|
||||
@@ -417,8 +460,9 @@ class EaseeGateway extends IPSModule
|
||||
return;
|
||||
}
|
||||
if (isset($nachricht['error'])) {
|
||||
$this->SetStatus(203);
|
||||
$this->setzeLetztenFehler('Easee-Ereignisfehler: ' . $nachricht['error']);
|
||||
$this->planeWiederverbindung(
|
||||
'Easee-Ereignisfehler: ' . $nachricht['error']
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,9 +471,9 @@ class EaseeGateway extends IPSModule
|
||||
return;
|
||||
}
|
||||
if ($typ === 7) {
|
||||
$this->SetBuffer('SignalRReady', '0');
|
||||
$this->setzeVerbunden(false);
|
||||
$this->setzeLetztenFehler('Easee-Ereignisverbindung wurde beendet.');
|
||||
$this->planeWiederverbindung(
|
||||
'Easee-Ereignisverbindung wurde beendet.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,6 +574,25 @@ class EaseeGateway extends IPSModule
|
||||
return;
|
||||
}
|
||||
|
||||
$this->speichereBeobachtungImCache($seriennummer, $id, $wert);
|
||||
|
||||
$this->SendDataToChildren(json_encode([
|
||||
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||
'Buffer' => json_encode([
|
||||
'type' => 'Observation',
|
||||
'serialNumber' => $seriennummer,
|
||||
'id' => $id,
|
||||
'value' => $wert,
|
||||
'timestamp' => time(),
|
||||
], JSON_THROW_ON_ERROR),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function speichereBeobachtungImCache(
|
||||
string $seriennummer,
|
||||
int $id,
|
||||
$wert
|
||||
): void {
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
if (!isset($cache[$seriennummer]) || !is_array($cache[$seriennummer])) {
|
||||
$cache[$seriennummer] = [];
|
||||
@@ -545,21 +608,84 @@ class EaseeGateway extends IPSModule
|
||||
'ObservationCache',
|
||||
json_encode($cache, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
$this->SendDataToChildren(json_encode([
|
||||
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||
'Buffer' => json_encode([
|
||||
'type' => 'Observation',
|
||||
'serialNumber' => $seriennummer,
|
||||
'id' => $id,
|
||||
'value' => $wert,
|
||||
'timestamp' => time(),
|
||||
], JSON_THROW_ON_ERROR),
|
||||
], JSON_THROW_ON_ERROR));
|
||||
private function markiereBeobachtungscacheVeraltet(): void
|
||||
{
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
foreach ($cache as &$zustand) {
|
||||
if (is_array($zustand)) {
|
||||
$zustand['updated'] = 0;
|
||||
}
|
||||
}
|
||||
unset($zustand);
|
||||
$this->WriteAttributeString(
|
||||
'ObservationCache',
|
||||
json_encode($cache, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
private function stelleAktuellenStatusSicher(string $seriennummer): void
|
||||
{
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
$zustand = $cache[$seriennummer] ?? [];
|
||||
$cacheIstAktuell = is_array($zustand)
|
||||
&& array_key_exists('109', $zustand)
|
||||
&& (int) ($zustand['updated'] ?? 0) >= time() - self::CACHE_MAXIMALALTER_SEKUNDEN;
|
||||
if (
|
||||
$cacheIstAktuell
|
||||
|| $this->ReadPropertyBoolean('Testmodus')
|
||||
|| $this->GetBuffer('SignalRReady') !== '1'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$antwort = $this->autorisierteApiAnfrage(
|
||||
'GET',
|
||||
'/state/' . rawurlencode($seriennummer) . '/observations?ids='
|
||||
. rawurlencode(implode(',', EaseeGatewayProtokoll::BEOBACHTUNGEN)),
|
||||
''
|
||||
);
|
||||
if (!($antwort['success'] ?? false)) {
|
||||
$this->setzeLetztenFehler(
|
||||
'Easee-Status konnte nicht geladen werden: '
|
||||
. (string) ($antwort['error'] ?? 'Unbekannter API-Fehler.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$daten = json_decode((string) ($antwort['body'] ?? ''), true);
|
||||
if (!is_array($daten)) {
|
||||
$this->setzeLetztenFehler('Easee-Statusantwort ist ungueltig.');
|
||||
return;
|
||||
}
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen(
|
||||
$daten,
|
||||
[$seriennummer],
|
||||
$seriennummer
|
||||
);
|
||||
$betriebsstatusGeladen = false;
|
||||
foreach ($beobachtungen as $beobachtung) {
|
||||
$this->speichereBeobachtungImCache(
|
||||
$beobachtung['Seriennummer'],
|
||||
$beobachtung['ID'],
|
||||
$beobachtung['Wert']
|
||||
);
|
||||
$betriebsstatusGeladen = $betriebsstatusGeladen
|
||||
|| $beobachtung['ID'] === 109;
|
||||
}
|
||||
if (!$betriebsstatusGeladen) {
|
||||
$this->setzeLetztenFehler(
|
||||
'Easee-Statusantwort enthaelt keinen Betriebsstatus (109).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
$this->setzeLetztenFehler('');
|
||||
}
|
||||
|
||||
private function erstelleStatusantwort(string $seriennummer): string
|
||||
{
|
||||
$this->stelleAktuellenStatusSicher($seriennummer);
|
||||
$cache = $this->leseAttributArray('ObservationCache');
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
@@ -606,6 +732,24 @@ class EaseeGateway extends IPSModule
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function sendeLadestart(string $seriennummer): array
|
||||
{
|
||||
if ($seriennummer === '') {
|
||||
return ['success' => false, 'error' => 'Seriennummer fehlt.'];
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
return ['success' => true, 'httpCode' => 200, 'body' => '{}'];
|
||||
}
|
||||
|
||||
return $this->autorisierteApiAnfrage(
|
||||
'POST',
|
||||
'/api/chargers/' . rawurlencode($seriennummer)
|
||||
. '/commands/start_charging',
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function autorisierteApiAnfrage(
|
||||
string $methode,
|
||||
@@ -758,9 +902,38 @@ class EaseeGateway extends IPSModule
|
||||
];
|
||||
}
|
||||
|
||||
private function setzeEreignisverbindung(
|
||||
bool $verbunden,
|
||||
string $fehler = ''
|
||||
): void {
|
||||
$this->SetBuffer('SignalRReady', $verbunden ? '1' : '0');
|
||||
$this->SetStatus($verbunden ? 102 : 203);
|
||||
$this->setzeLetztenFehler($verbunden ? '' : $fehler);
|
||||
if ($verbunden) {
|
||||
$this->SetTimerInterval('ReconnectTimer', 0);
|
||||
}
|
||||
$this->setzeVerbunden($verbunden);
|
||||
}
|
||||
|
||||
private function planeWiederverbindung(string $fehler): void
|
||||
{
|
||||
$this->setzeEreignisverbindung(false, $fehler);
|
||||
$this->SetBuffer('LastNegotiation', '0');
|
||||
if (
|
||||
$this->ReadPropertyBoolean('Active')
|
||||
&& !$this->ReadPropertyBoolean('Testmodus')
|
||||
) {
|
||||
$this->SetTimerInterval('ReconnectTimer', 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private function setzeVerbunden(bool $verbunden): void
|
||||
{
|
||||
$warVerbunden = (bool) $this->GetValue('Connected');
|
||||
$this->SetValue('Connected', $verbunden);
|
||||
if ($warVerbunden === $verbunden) {
|
||||
return;
|
||||
}
|
||||
$this->SendDataToChildren(json_encode([
|
||||
'DataID' => self::CHILD_EVENT_DATA_ID,
|
||||
'Buffer' => json_encode([
|
||||
|
||||
+269
-71
@@ -28,20 +28,26 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
private const STATUS_GATEWAY_GETRENNT = 202;
|
||||
private const STATUS_WARTET_AUF_DATEN = 203;
|
||||
private const STATUS_LADEFEHLER = 204;
|
||||
private const REGELUEBERGANG_TIMEOUT_MS = 2000;
|
||||
|
||||
/** @var list<string> */
|
||||
private const DIAGNOSE_VARIABLEN = [
|
||||
'Istleistung',
|
||||
'Leistungsquelle',
|
||||
'Sollleistung',
|
||||
'SollwertGueltig',
|
||||
'Verfuegbar',
|
||||
'AenderungMoeglich',
|
||||
'Stoerung',
|
||||
'Stoertext',
|
||||
'Fahrzeugstatus',
|
||||
'GatewayVerbunden',
|
||||
'ApiMaximalstrom',
|
||||
'EaseeAktiviert',
|
||||
'AutorisierungErforderlich',
|
||||
'RemoteStartErforderlich',
|
||||
'SmartCharging',
|
||||
'GrundKeinLadestrom',
|
||||
'LetzterGeraetebefehl',
|
||||
'LetzterStartbefehl',
|
||||
'LeistungsangebotDiagnose',
|
||||
];
|
||||
|
||||
@@ -53,7 +59,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->RegisterPropertyInteger('Betriebsmodus', EaseeLadestatus::BETRIEBSMODUS_EASEE);
|
||||
$this->RegisterPropertyString('Ladestationskennung', '');
|
||||
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
||||
$this->RegisterPropertyBoolean('Ladefreigabe', false);
|
||||
$this->RegisterPropertyBoolean('Ladefreigabe', true);
|
||||
$this->RegisterPropertyBoolean('Solarladen', true);
|
||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||
|
||||
@@ -69,7 +75,6 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
'~Switch',
|
||||
110
|
||||
);
|
||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
|
||||
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
|
||||
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
|
||||
|
||||
@@ -82,6 +87,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->RegisterAttributeString('EaseeBeobachtungen', '{}');
|
||||
$this->RegisterAttributeInteger('LetzteBeobachtung', 0);
|
||||
$this->RegisterAttributeInteger('ApiMaximalstrom', 16);
|
||||
$this->RegisterAttributeBoolean('ZustandEaseeAktiviert', true);
|
||||
$this->RegisterAttributeBoolean('ZustandAutorisierungErforderlich', false);
|
||||
$this->RegisterAttributeBoolean('ZustandRemoteStartErforderlich', false);
|
||||
$this->RegisterAttributeBoolean('ZustandSmartCharging', false);
|
||||
$this->RegisterAttributeInteger('ZustandGrundKeinLadestrom', 0);
|
||||
$this->RegisterAttributeBoolean('ZustandFahrzeugVerbunden', false);
|
||||
$this->RegisterAttributeBoolean('ZustandFahrzeugGeladen', false);
|
||||
$this->RegisterAttributeInteger('ZustandFahrzeugstatus', 0);
|
||||
@@ -99,7 +109,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
|
||||
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
$this->RegisterAttributeBoolean('LadestartFuerVerbindungGesendet', false);
|
||||
$this->RegisterAttributeString('LetzterGeraetebefehl', '');
|
||||
$this->RegisterAttributeString('LetzterStartbefehl', '');
|
||||
$this->RegisterAttributeBoolean('RegeluebergangAktiv', false);
|
||||
|
||||
$this->RegisterTimer(
|
||||
'Meldezyklus',
|
||||
@@ -116,6 +129,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'RegeluebergangTimeout',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'RegeluebergangTimeout', false);"
|
||||
);
|
||||
|
||||
$this->ConnectParent(self::EASEE_GATEWAY_MODULE_ID);
|
||||
}
|
||||
@@ -126,8 +144,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
|
||||
$this->initialisiereLokaleEinstellungen();
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
$this->WriteAttributeBoolean('GatewayVerbunden', false);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false);
|
||||
$this->beendeRegeluebergang();
|
||||
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
@@ -153,17 +174,27 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
{
|
||||
switch ($ident) {
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$aktiv = (bool) $wert;
|
||||
$this->SetValue('Aktiv', $aktiv);
|
||||
if ($aktiv) {
|
||||
$this->starteRegeluebergang();
|
||||
} else {
|
||||
$this->beendeRegeluebergang();
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Ladefreigabe':
|
||||
$this->WriteAttributeBoolean('ZustandLadefreigabe', (bool) $wert);
|
||||
$this->setzeSichtbareVariable('Ladefreigabe', (bool) $wert);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$ladefreigabe = (bool) $wert;
|
||||
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
|
||||
$this->setzeSichtbareVariable('Ladefreigabe', $ladefreigabe);
|
||||
if ($ladefreigabe) {
|
||||
$this->starteRegeluebergang();
|
||||
} else {
|
||||
$this->beendeRegeluebergang();
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -171,9 +202,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$solarladen = EaseeLadestatus::solarladenErzwungen(
|
||||
$this->ReadPropertyInteger('Betriebsmodus')
|
||||
) ? true : (bool) $wert;
|
||||
if ($solarladen !== $this->ReadAttributeBoolean('ZustandSolarladen')) {
|
||||
$this->starteRegeluebergang();
|
||||
}
|
||||
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
|
||||
$this->setzeSichtbareVariable('Solarladen', $solarladen);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -183,7 +216,12 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
|
||||
case 'VorgabeTimeout':
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'RegeluebergangTimeout':
|
||||
$this->beendeRegeluebergang();
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -274,7 +312,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
}
|
||||
|
||||
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
|
||||
$this->aktualisiere(false);
|
||||
$this->aktualisiere(false, false);
|
||||
$sollleistung = $daten['Sollleistung_W'];
|
||||
if ($sollleistung === null) {
|
||||
if (
|
||||
@@ -285,7 +323,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
true
|
||||
)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(false);
|
||||
}
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
@@ -297,6 +335,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
);
|
||||
}
|
||||
|
||||
$this->beendeRegeluebergang();
|
||||
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
|
||||
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
|
||||
$this->setzeZustand('Sollleistung', $sollleistung);
|
||||
@@ -391,7 +430,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->WriteAttributeInteger('LetzteBeobachtung', $zeitpunkt);
|
||||
}
|
||||
|
||||
private function aktualisiere(bool $meldungPlanen): void
|
||||
private function aktualisiere(
|
||||
bool $meldungPlanen,
|
||||
bool $steuerungAusfuehren = true
|
||||
): void
|
||||
{
|
||||
try {
|
||||
$status = EaseeLadestatus::ausBeobachtungen(
|
||||
@@ -405,6 +447,11 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
'Phasenzahl',
|
||||
'Istleistung_W',
|
||||
'Ladestrom_A',
|
||||
'EaseeAktiviert',
|
||||
'AutorisierungErforderlich',
|
||||
'RemoteStartErforderlich',
|
||||
'SmartCharging',
|
||||
'GrundKeinLadestrom',
|
||||
] as $feld) {
|
||||
$this->setzeZustand($feld, $status[$feld]);
|
||||
}
|
||||
@@ -416,13 +463,19 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
'ApiMaximalstrom',
|
||||
$status['ApiMaximalstrom_A']
|
||||
);
|
||||
if (!$status['FahrzeugVerbunden']) {
|
||||
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', false);
|
||||
}
|
||||
|
||||
$gatewayVerbunden = $this->ReadAttributeBoolean('GatewayVerbunden');
|
||||
$phaseGueltig = in_array($status['Phasenzahl'], [1, 3], true);
|
||||
$regelPhasenzahl = $phaseGueltig ? $status['Phasenzahl'] : 1;
|
||||
$phaseErforderlich = $status['Fahrzeugstatus'] === 3;
|
||||
$steuerbar = $gatewayVerbunden
|
||||
&& $status['StatusGueltig']
|
||||
&& $status['EaseeAktiviert']
|
||||
&& !$status['Stoerung']
|
||||
&& (!$status['FahrzeugVerbunden'] || $phaseGueltig);
|
||||
&& (!$phaseErforderlich || $phaseGueltig);
|
||||
|
||||
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
|
||||
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
|
||||
@@ -432,7 +485,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->ReadAttributeBoolean('ZustandLadefreigabe'),
|
||||
$steuerbar && $status['FahrzeugVerbunden'],
|
||||
$status['FahrzeugGeladen'],
|
||||
$phaseGueltig ? $status['Phasenzahl'] : 1,
|
||||
$regelPhasenzahl,
|
||||
$status['ApiMaximalstrom_A'],
|
||||
$solarladen,
|
||||
$peakbetrieb
|
||||
@@ -460,19 +513,29 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
} elseif ($status['Stoerung']) {
|
||||
$stoertext = $status['Stoertext'];
|
||||
$this->SetStatus(self::STATUS_LADEFEHLER);
|
||||
} elseif ($status['FahrzeugVerbunden'] && !$phaseGueltig) {
|
||||
$stoertext = 'Easee meldet keine unterstuetzte aktive Ausgangsphase.';
|
||||
} elseif ($phaseErforderlich && !$phaseGueltig) {
|
||||
$stoertext = 'Easee meldet beim Laden keine unterstuetzte aktive Ausgangsphase.';
|
||||
$this->SetStatus(self::STATUS_WARTET_AUF_DATEN);
|
||||
} else {
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
}
|
||||
$this->setzeStoerung($stoertext);
|
||||
|
||||
$regeluebergangAktiv = (bool) $this->GetValue('Aktiv')
|
||||
&& $this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||
&& $steuerbar
|
||||
&& $status['FahrzeugVerbunden']
|
||||
&& !$status['FahrzeugGeladen']
|
||||
&& $this->ReadAttributeBoolean('RegeluebergangAktiv');
|
||||
if ($gatewayVerbunden && $status['StatusGueltig']) {
|
||||
$this->setzeLadestrom(
|
||||
$this->bestimmeWirksameSollleistung($angebot),
|
||||
$status['Phasenzahl']
|
||||
);
|
||||
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
|
||||
if ($steuerungAusfuehren && !$regeluebergangAktiv) {
|
||||
$this->setzeLadestrom(
|
||||
$sollleistung,
|
||||
$regelPhasenzahl,
|
||||
$status['Fahrzeugstatus'] === 2
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
}
|
||||
@@ -497,7 +560,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
if (in_array($sollleistung, $angebot, true)) {
|
||||
return $sollleistung;
|
||||
}
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -510,34 +573,64 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function setzeLadestrom(int $leistung, int $phasenzahl): void
|
||||
{
|
||||
private function setzeLadestrom(
|
||||
int $leistung,
|
||||
int $phasenzahl,
|
||||
bool $wartetAufStart
|
||||
): void {
|
||||
$strom = $leistung === 0
|
||||
? 0
|
||||
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
|
||||
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
|
||||
return;
|
||||
if ($strom !== $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
|
||||
$antwort = $this->gatewayAnfrage([
|
||||
'action' => 'SetDynamicChargerCurrent',
|
||||
'serialNumber' => $this->seriennummer(),
|
||||
'amps' => $strom,
|
||||
]);
|
||||
if (!($antwort['success'] ?? false)) {
|
||||
throw new RuntimeException(
|
||||
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
|
||||
);
|
||||
}
|
||||
|
||||
$befehl = json_encode([
|
||||
'Aktion' => 'SetDynamicChargerCurrent',
|
||||
'Seriennummer' => $this->seriennummer(),
|
||||
'Ampere' => $strom,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
|
||||
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
||||
}
|
||||
|
||||
if (
|
||||
$strom > 0
|
||||
&& $wartetAufStart
|
||||
&& !$this->ReadAttributeBoolean('LadestartFuerVerbindungGesendet')
|
||||
) {
|
||||
$this->sendeLadestart();
|
||||
}
|
||||
}
|
||||
|
||||
private function sendeLadestart(): void
|
||||
{
|
||||
$antwort = $this->gatewayAnfrage([
|
||||
'action' => 'SetDynamicChargerCurrent',
|
||||
'action' => 'StartCharging',
|
||||
'serialNumber' => $this->seriennummer(),
|
||||
'amps' => $strom,
|
||||
]);
|
||||
if (!($antwort['success'] ?? false)) {
|
||||
throw new RuntimeException(
|
||||
(string) ($antwort['error'] ?? 'Easee-Stromvorgabe fehlgeschlagen.')
|
||||
(string) ($antwort['error'] ?? 'Easee-Ladestart fehlgeschlagen.')
|
||||
);
|
||||
}
|
||||
|
||||
$befehl = json_encode([
|
||||
'Aktion' => 'SetDynamicChargerCurrent',
|
||||
'Aktion' => 'StartCharging',
|
||||
'Seriennummer' => $this->seriennummer(),
|
||||
'Ampere' => $strom,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$this->WriteAttributeString('LetzterGeraetebefehl', $befehl);
|
||||
$this->setzeSichtbareVariable('LetzterGeraetebefehl', $befehl);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', $strom);
|
||||
$this->WriteAttributeBoolean('LadestartFuerVerbindungGesendet', true);
|
||||
$this->WriteAttributeString('LetzterStartbefehl', $befehl);
|
||||
$this->setzeSichtbareVariable('LetzterStartbefehl', $befehl);
|
||||
}
|
||||
|
||||
private function seriennummer(): string
|
||||
@@ -648,6 +741,36 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
'Wert' => $this->ReadAttributeBoolean('ZustandSolarladen'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'EaseeAktiviert',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('EaseeAktiviert'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'AutorisierungErforderlich',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('AutorisierungErforderlich'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'RemoteStartErforderlich',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('RemoteStartErforderlich'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'SmartCharging',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (bool) $this->leseZustand('SmartCharging'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'GrundKeinLadestrom',
|
||||
'Art' => 'Status',
|
||||
'Wert' => (int) $this->leseZustand('GrundKeinLadestrom'),
|
||||
'Einheit' => '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Ladefehler',
|
||||
'Art' => 'Stoerung',
|
||||
@@ -711,6 +834,23 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
return is_array($zuordnung) ? $zuordnung : [];
|
||||
}
|
||||
|
||||
private function starteRegeluebergang(): void
|
||||
{
|
||||
if ($this->zugeordneteManagerIDs() === []) {
|
||||
$this->beendeRegeluebergang();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->WriteAttributeBoolean('RegeluebergangAktiv', true);
|
||||
$this->SetTimerInterval('RegeluebergangTimeout', self::REGELUEBERGANG_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private function beendeRegeluebergang(): void
|
||||
{
|
||||
$this->WriteAttributeBoolean('RegeluebergangAktiv', false);
|
||||
$this->SetTimerInterval('RegeluebergangTimeout', 0);
|
||||
}
|
||||
|
||||
private function pruefeKonfiguration(): void
|
||||
{
|
||||
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
|
||||
@@ -771,59 +911,63 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
|
||||
private function aktualisiereVariablen(): void
|
||||
{
|
||||
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||
$this->RegisterVariableBoolean(
|
||||
'Ladefreigabe',
|
||||
'Ladefreigabe',
|
||||
'~Switch',
|
||||
150
|
||||
);
|
||||
$this->RegisterVariableBoolean(
|
||||
'Solarladen',
|
||||
'Solarladen',
|
||||
'~Switch',
|
||||
160
|
||||
);
|
||||
$this->EnableAction('Ladefreigabe');
|
||||
$this->EnableAction('Solarladen');
|
||||
$this->SetValue(
|
||||
'Ladefreigabe',
|
||||
$this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||
);
|
||||
$this->SetValue(
|
||||
'Solarladen',
|
||||
$this->ReadAttributeBoolean('ZustandSolarladen')
|
||||
);
|
||||
} else {
|
||||
$this->entferneVariable('Ladefreigabe');
|
||||
$this->entferneVariable('Solarladen');
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
||||
$this->registriereVerbraucherDiagnose();
|
||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200);
|
||||
$this->RegisterVariableBoolean(
|
||||
'GatewayVerbunden',
|
||||
'Easee Gateway verbunden',
|
||||
'~Switch',
|
||||
200
|
||||
210
|
||||
);
|
||||
$this->RegisterVariableInteger('ApiMaximalstrom', 'API-Maximalstrom', '', 220);
|
||||
$this->RegisterVariableBoolean(
|
||||
'EaseeAktiviert',
|
||||
'Easee aktiviert',
|
||||
'~Switch',
|
||||
230
|
||||
);
|
||||
$this->RegisterVariableBoolean(
|
||||
'AutorisierungErforderlich',
|
||||
'Autorisierung erforderlich',
|
||||
'~Switch',
|
||||
240
|
||||
);
|
||||
$this->RegisterVariableBoolean(
|
||||
'RemoteStartErforderlich',
|
||||
'Remote-Start erforderlich',
|
||||
'~Switch',
|
||||
250
|
||||
);
|
||||
$this->RegisterVariableBoolean(
|
||||
'SmartCharging',
|
||||
'Easee Smart Charging',
|
||||
'~Switch',
|
||||
260
|
||||
);
|
||||
$this->RegisterVariableInteger(
|
||||
'ApiMaximalstrom',
|
||||
'API-Maximalstrom',
|
||||
'GrundKeinLadestrom',
|
||||
'Grund kein Ladestrom',
|
||||
'',
|
||||
210
|
||||
270
|
||||
);
|
||||
$this->RegisterVariableString(
|
||||
'LetzterGeraetebefehl',
|
||||
'Letzter Geraetebefehl',
|
||||
'',
|
||||
220
|
||||
280
|
||||
);
|
||||
$this->RegisterVariableString(
|
||||
'LetzterStartbefehl',
|
||||
'Letzter Startbefehl',
|
||||
'',
|
||||
290
|
||||
);
|
||||
$this->RegisterVariableString(
|
||||
'LeistungsangebotDiagnose',
|
||||
'Leistungsangebot',
|
||||
'',
|
||||
230
|
||||
300
|
||||
);
|
||||
$this->SetValue(
|
||||
'LeistungsangebotDiagnose',
|
||||
@@ -833,11 +977,16 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
'LetzterGeraetebefehl',
|
||||
$this->ReadAttributeString('LetzterGeraetebefehl')
|
||||
);
|
||||
$this->SetValue(
|
||||
'LetzterStartbefehl',
|
||||
$this->ReadAttributeString('LetzterStartbefehl')
|
||||
);
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
if (!in_array($ident, [
|
||||
'GatewayVerbunden',
|
||||
'ApiMaximalstrom',
|
||||
'LetzterGeraetebefehl',
|
||||
'LetzterStartbefehl',
|
||||
'LeistungsangebotDiagnose',
|
||||
], true)) {
|
||||
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
|
||||
@@ -856,6 +1005,40 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->entferneVariable($ident);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||
$this->RegisterVariableBoolean(
|
||||
'Ladefreigabe',
|
||||
'Ladefreigabe',
|
||||
'~Switch',
|
||||
150
|
||||
);
|
||||
$this->RegisterVariableBoolean(
|
||||
'Solarladen',
|
||||
'Solarladen',
|
||||
'~Switch',
|
||||
160
|
||||
);
|
||||
$this->RegisterVariableFloat('Istleistung', 'Istleistung', '', 170);
|
||||
$this->RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 180);
|
||||
$this->EnableAction('Ladefreigabe');
|
||||
$this->EnableAction('Solarladen');
|
||||
$this->SetValue(
|
||||
'Ladefreigabe',
|
||||
$this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||
);
|
||||
$this->SetValue(
|
||||
'Solarladen',
|
||||
$this->ReadAttributeBoolean('ZustandSolarladen')
|
||||
);
|
||||
$this->SetValue('Istleistung', $this->ReadAttributeFloat('ZustandIstleistung'));
|
||||
$this->SetValue('Sollleistung', $this->ReadAttributeInteger('ZustandSollleistung'));
|
||||
} else {
|
||||
$this->entferneVariable('Ladefreigabe');
|
||||
$this->entferneVariable('Solarladen');
|
||||
$this->entferneVariable('Istleistung');
|
||||
$this->entferneVariable('Sollleistung');
|
||||
}
|
||||
}
|
||||
|
||||
private function entferneVariable(string $ident): void
|
||||
@@ -872,6 +1055,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
switch ($ident) {
|
||||
case 'FahrzeugVerbunden':
|
||||
case 'FahrzeugGeladen':
|
||||
case 'EaseeAktiviert':
|
||||
case 'AutorisierungErforderlich':
|
||||
case 'RemoteStartErforderlich':
|
||||
case 'SmartCharging':
|
||||
case 'SollwertGueltig':
|
||||
case 'Verfuegbar':
|
||||
case 'AenderungMoeglich':
|
||||
@@ -880,6 +1067,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
break;
|
||||
case 'Fahrzeugstatus':
|
||||
case 'Phasenzahl':
|
||||
case 'GrundKeinLadestrom':
|
||||
case 'Sollleistung':
|
||||
$this->WriteAttributeInteger('Zustand' . $ident, (int) $wert);
|
||||
break;
|
||||
@@ -915,6 +1103,10 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
if (in_array($ident, [
|
||||
'FahrzeugVerbunden',
|
||||
'FahrzeugGeladen',
|
||||
'EaseeAktiviert',
|
||||
'AutorisierungErforderlich',
|
||||
'RemoteStartErforderlich',
|
||||
'SmartCharging',
|
||||
'SollwertGueltig',
|
||||
'Verfuegbar',
|
||||
'AenderungMoeglich',
|
||||
@@ -925,6 +1117,7 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
if (in_array($ident, [
|
||||
'Fahrzeugstatus',
|
||||
'Phasenzahl',
|
||||
'GrundKeinLadestrom',
|
||||
'Sollleistung',
|
||||
], true)) {
|
||||
return $this->ReadAttributeInteger('Zustand' . $ident);
|
||||
@@ -971,7 +1164,12 @@ class LadestationGateway extends IPSModule implements VerbraucherSchnittstelle
|
||||
|
||||
private function deaktiviereTimer(): void
|
||||
{
|
||||
foreach (['Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
|
||||
foreach ([
|
||||
'Meldezyklus',
|
||||
'RueckmeldungVerzoegert',
|
||||
'VorgabeTimeout',
|
||||
'RegeluebergangTimeout',
|
||||
] as $timer) {
|
||||
$this->SetTimerInterval($timer, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,41 @@
|
||||
"caption": "Geraetestatus abfragen",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Phasenerkennungszeit",
|
||||
"caption": "Phasen erkennen nach",
|
||||
"suffix": " s",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "FahrzeugstromErkennungszeit",
|
||||
"caption": "Fahrzeug-Maximalstrom erkennen nach",
|
||||
"suffix": " s",
|
||||
"minimum": 30
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "ZeitZwischenZustandswechseln",
|
||||
"caption": "Zeit zwischen Leistungsaenderungen",
|
||||
"suffix": " min",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Mindesteinschaltdauer",
|
||||
"caption": "Mindesteinschaltdauer",
|
||||
"suffix": " min",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Mindestausschaltdauer",
|
||||
"caption": "Mindestausschaltdauer",
|
||||
"suffix": " min",
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -23,17 +23,19 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
private const STATUS_AKTIV = 102;
|
||||
private const STATUS_KONFIGURATION_UNGUELTIG = 201;
|
||||
private const STATUS_KOMMUNIKATIONSFEHLER = 202;
|
||||
private const REGELUEBERGANG_TIMEOUT_MS = 2000;
|
||||
private const PHASENERKENNUNGSSTROM = 6;
|
||||
|
||||
/** @var list<string> */
|
||||
private const DIAGNOSE_VARIABLEN = [
|
||||
'Istleistung',
|
||||
'Leistungsquelle',
|
||||
'Sollleistung',
|
||||
'SollwertGueltig',
|
||||
'Verfuegbar',
|
||||
'AenderungMoeglich',
|
||||
'Stoerung',
|
||||
'Stoertext',
|
||||
'Fahrzeugstatus',
|
||||
'FahrzeugMaximalstrom',
|
||||
'LetzterGeraetebefehl',
|
||||
'LeistungsangebotDiagnose',
|
||||
];
|
||||
@@ -50,8 +52,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->RegisterPropertyString('Benutzername', '');
|
||||
$this->RegisterPropertyString('Passwort', '');
|
||||
$this->RegisterPropertyInteger('MaximalerLadestrom', 16);
|
||||
$this->RegisterPropertyInteger('Phasenerkennungszeit', 60);
|
||||
$this->RegisterPropertyInteger('FahrzeugstromErkennungszeit', 90);
|
||||
$this->RegisterPropertyInteger('ZeitZwischenZustandswechseln', 1);
|
||||
$this->RegisterPropertyInteger('Mindesteinschaltdauer', 0);
|
||||
$this->RegisterPropertyInteger('Mindestausschaltdauer', 0);
|
||||
$this->RegisterPropertyInteger('Abfrageintervall', 5);
|
||||
$this->RegisterPropertyBoolean('Ladefreigabe', false);
|
||||
$this->RegisterPropertyBoolean('Ladefreigabe', true);
|
||||
$this->RegisterPropertyBoolean('Solarladen', true);
|
||||
$this->RegisterPropertyBoolean('DiagnosevariablenAnzeigen', false);
|
||||
|
||||
@@ -61,7 +68,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
|
||||
$this->RegisterVariableBoolean('FahrzeugVerbunden', 'Fahrzeug verbunden', '~Switch', 100);
|
||||
$this->RegisterVariableBoolean('FahrzeugGeladen', 'Fahrzeug geladen', '~Switch', 110);
|
||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 120);
|
||||
$this->RegisterVariableFloat('Ladestrom', 'Ladestrom', '', 130);
|
||||
$this->RegisterVariableInteger('Phasenzahl', 'Phasenzahl', '', 140);
|
||||
|
||||
@@ -87,6 +93,18 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->RegisterAttributeInteger('LetzteManagerID', 0);
|
||||
$this->RegisterAttributeInteger('LetzteVorgabeZeit', 0);
|
||||
$this->RegisterAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
$this->RegisterAttributeInteger('LetzteGesetzteLeistung', 0);
|
||||
$this->RegisterAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||
$this->RegisterAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||
$this->RegisterAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||
$this->RegisterAttributeInteger('PhasenerkennungSeit', 0);
|
||||
$this->RegisterAttributeInteger('PhasenerkennungVersion', 0);
|
||||
$this->RegisterAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||
$this->RegisterAttributeInteger('GeraeteMaximalstrom', 32);
|
||||
$this->RegisterAttributeInteger('ErkannterMaximalstrom', 32);
|
||||
$this->RegisterAttributeInteger('FahrzeugstromKandidat', 0);
|
||||
$this->RegisterAttributeInteger('FahrzeugstromKandidatSeit', 0);
|
||||
$this->RegisterAttributeBoolean('RegeluebergangAktiv', false);
|
||||
|
||||
$this->RegisterTimer(
|
||||
'Statuszyklus',
|
||||
@@ -108,6 +126,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'VorgabeTimeout', false);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'RegeluebergangTimeout',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'RegeluebergangTimeout', false);"
|
||||
);
|
||||
}
|
||||
|
||||
public function ApplyChanges(): void
|
||||
@@ -116,6 +139,30 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
|
||||
$this->initialisiereLokaleEinstellungen();
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
|
||||
// Ein persistiertes Ladeende nach Neueinrichtung oder Modulupdate einmal neu pruefen.
|
||||
$this->setzeZustand('FahrzeugGeladen', false);
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', -1);
|
||||
$this->WriteAttributeInteger('LetzteGesetzteLeistung', 0);
|
||||
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
if ($this->ReadAttributeInteger('PhasenerkennungVersion') < 1) {
|
||||
$this->WriteAttributeInteger(
|
||||
'ZustandPhasenzahl',
|
||||
LadestationRegler::PHASEN_UNBEKANNT
|
||||
);
|
||||
$this->WriteAttributeInteger('PhasenerkennungVersion', 1);
|
||||
}
|
||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||
$this->WriteAttributeInteger(
|
||||
'ErkannterMaximalstrom',
|
||||
$this->ReadPropertyInteger('MaximalerLadestrom')
|
||||
);
|
||||
$this->setzeFahrzeugstromKandidatZurueck();
|
||||
$this->beendeRegeluebergang();
|
||||
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
@@ -138,18 +185,37 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
{
|
||||
switch ($ident) {
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$aktiv = (bool) $wert;
|
||||
$this->SetValue('Aktiv', $aktiv);
|
||||
if ($aktiv) {
|
||||
$this->starteRegeluebergang();
|
||||
} else {
|
||||
$this->beendeRegeluebergang();
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Ladefreigabe':
|
||||
$ladefreigabe = (bool) $wert;
|
||||
$this->WriteAttributeBoolean('ZustandLadefreigabe', $ladefreigabe);
|
||||
$this->setzeSichtbareVariable('Ladefreigabe', $ladefreigabe);
|
||||
if ($ladefreigabe) {
|
||||
$this->starteRegeluebergang();
|
||||
} else {
|
||||
$this->beendeRegeluebergang();
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'Solarladen':
|
||||
$this->WriteAttributeBoolean('Zustand' . $ident, (bool) $wert);
|
||||
$this->setzeSichtbareVariable($ident, (bool) $wert);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$solarladen = (bool) $wert;
|
||||
if ($solarladen !== $this->ReadAttributeBoolean('ZustandSolarladen')) {
|
||||
$this->starteRegeluebergang();
|
||||
}
|
||||
$this->WriteAttributeBoolean('ZustandSolarladen', $solarladen);
|
||||
$this->setzeSichtbareVariable('Solarladen', $solarladen);
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -159,7 +225,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
|
||||
case 'VorgabeTimeout':
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
case 'RegeluebergangTimeout':
|
||||
$this->beendeRegeluebergang();
|
||||
$this->aktualisiere(true);
|
||||
return;
|
||||
|
||||
@@ -167,7 +238,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
if ((bool) $wert) {
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
|
||||
}
|
||||
$this->aktualisiere(false);
|
||||
$this->sendeVerbraucherdaten();
|
||||
return;
|
||||
|
||||
@@ -200,13 +270,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
}
|
||||
|
||||
$this->WriteAttributeString('Betriebsart', $daten['Betriebsart']);
|
||||
$this->aktualisiere(false, false);
|
||||
$this->aktualisiere(false, false, false);
|
||||
$sollleistung = $daten['Sollleistung_W'];
|
||||
if ($sollleistung === null) {
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->aktualisiere(false, false);
|
||||
}
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
@@ -218,6 +288,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
);
|
||||
}
|
||||
|
||||
$this->beendeRegeluebergang();
|
||||
$this->WriteAttributeInteger('LetzteManagerID', $managerID);
|
||||
$this->WriteAttributeInteger('LetzteVorgabeZeit', time());
|
||||
$this->setzeZustand('Sollleistung', $sollleistung);
|
||||
@@ -227,7 +298,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
}
|
||||
|
||||
private function aktualisiere(bool $meldungPlanen, bool $statusLesen = true): void
|
||||
private function aktualisiere(
|
||||
bool $meldungPlanen,
|
||||
bool $statusLesen = true,
|
||||
bool $steuerungAusfuehren = true
|
||||
): void
|
||||
{
|
||||
try {
|
||||
if ($statusLesen) {
|
||||
@@ -239,7 +314,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$verbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
|
||||
$geladen = $this->ReadAttributeBoolean('ZustandFahrzeugGeladen');
|
||||
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
|
||||
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||
$maximalstrom = min(
|
||||
$this->ReadPropertyInteger('MaximalerLadestrom'),
|
||||
max(6, $this->ReadAttributeInteger('GeraeteMaximalstrom')),
|
||||
max(6, $this->ReadAttributeInteger('ErkannterMaximalstrom'))
|
||||
);
|
||||
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom);
|
||||
$solarladen = $this->ReadAttributeBoolean('ZustandSolarladen');
|
||||
$peakbetrieb = $this->ReadAttributeString('Betriebsart')
|
||||
=== Nachrichtenvertrag::BETRIEBSART_PEAK;
|
||||
@@ -263,6 +343,40 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$solarladen,
|
||||
$peakbetrieb
|
||||
);
|
||||
if ($aktiv
|
||||
&& $ladefreigabe
|
||||
&& $verbunden
|
||||
&& !$geladen
|
||||
&& !($peakbetrieb && $solarladen)
|
||||
&& $phasenzahl !== LadestationRegler::PHASEN_UNBEKANNT
|
||||
) {
|
||||
$jetzt = time();
|
||||
$letzteLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
|
||||
if ($jetzt < $this->ReadAttributeInteger('LeistungswechselGesperrtBis')) {
|
||||
$angebot = [$letzteLeistung];
|
||||
} elseif ($letzteLeistung > 0
|
||||
&& $jetzt < $this->ReadAttributeInteger('AusschaltenGesperrtBis')
|
||||
) {
|
||||
$positiveStufen = array_values(array_filter(
|
||||
LadestationRegler::leistungsangebot(
|
||||
$aktiv,
|
||||
$ladefreigabe,
|
||||
$verbunden,
|
||||
$geladen,
|
||||
$phasenzahl,
|
||||
$maximalstrom,
|
||||
$solarladen,
|
||||
false
|
||||
),
|
||||
static fn (int $wert): bool => $wert > 0
|
||||
));
|
||||
$angebot = $positiveStufen !== [] ? $positiveStufen : [$letzteLeistung];
|
||||
} elseif ($letzteLeistung <= 0
|
||||
&& $jetzt < $this->ReadAttributeInteger('EinschaltenGesperrtBis')
|
||||
) {
|
||||
$angebot = [0];
|
||||
}
|
||||
}
|
||||
$angebotJson = json_encode($angebot, JSON_THROW_ON_ERROR);
|
||||
$this->WriteAttributeString('Leistungsangebot', $angebotJson);
|
||||
$this->setzeSichtbareVariable('LeistungsangebotDiagnose', $angebotJson);
|
||||
@@ -270,7 +384,19 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->setzeZustand('AenderungMoeglich', $verfuegbar && count($angebot) > 1);
|
||||
|
||||
$sollleistung = $this->bestimmeWirksameSollleistung($angebot);
|
||||
$this->setzeLadestrom($sollleistung);
|
||||
$phasenerkennungAktiv = $aktiv
|
||||
&& $ladefreigabe
|
||||
&& $verbunden
|
||||
&& !$geladen
|
||||
&& $phasenzahl === LadestationRegler::PHASEN_UNBEKANNT;
|
||||
$regeluebergangAktiv = $aktiv
|
||||
&& $ladefreigabe
|
||||
&& $verbunden
|
||||
&& !$geladen
|
||||
&& $this->ReadAttributeBoolean('RegeluebergangAktiv');
|
||||
if ($steuerungAusfuehren && !$phasenerkennungAktiv && !$regeluebergangAktiv) {
|
||||
$this->setzeLadestrom($sollleistung);
|
||||
}
|
||||
$this->setzeStoerung('');
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
} catch (Throwable $fehler) {
|
||||
@@ -299,16 +425,134 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->ReadPropertyInteger('Geraetetyp'),
|
||||
$antwort
|
||||
);
|
||||
if (isset($rohstatus['Sollstrom_A'])) {
|
||||
$geraeteSollstrom = (int) $rohstatus['Sollstrom_A'];
|
||||
if ($geraeteSollstrom === 0 || ($geraeteSollstrom >= 6 && $geraeteSollstrom <= 32)) {
|
||||
$this->WriteAttributeInteger('LetzterGesetzterStrom', $geraeteSollstrom);
|
||||
}
|
||||
}
|
||||
if (isset($rohstatus['Maximalstrom_A'])) {
|
||||
$this->WriteAttributeInteger('GeraeteMaximalstrom', (int) $rohstatus['Maximalstrom_A']);
|
||||
}
|
||||
|
||||
$warVerbunden = $this->ReadAttributeBoolean('ZustandFahrzeugVerbunden');
|
||||
$verbunden = $rohstatus['Rohstatus'] !== 1;
|
||||
$bekanntePhasenzahl = $warVerbunden
|
||||
? $this->ReadAttributeInteger('ZustandPhasenzahl')
|
||||
: LadestationRegler::PHASEN_UNBEKANNT;
|
||||
|
||||
if (!$verbunden) {
|
||||
$bekanntePhasenzahl = LadestationRegler::PHASEN_UNBEKANNT;
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||
} elseif ($bekanntePhasenzahl === LadestationRegler::PHASEN_UNBEKANNT) {
|
||||
$direktErkanntePhasen = (int) ($rohstatus['Phasenzahl']
|
||||
?? LadestationRegler::PHASEN_UNBEKANNT);
|
||||
if (in_array(
|
||||
$direktErkanntePhasen,
|
||||
[LadestationRegler::PHASEN_EIN, LadestationRegler::PHASEN_DREI],
|
||||
true
|
||||
)) {
|
||||
$bekanntePhasenzahl = $direktErkanntePhasen;
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
} elseif ($rohstatus['Istleistung_W'] > 7500.0) {
|
||||
$bekanntePhasenzahl = LadestationRegler::PHASEN_DREI;
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
} elseif ((bool) $this->GetValue('Aktiv')
|
||||
&& $this->ReadAttributeBoolean('ZustandLadefreigabe')
|
||||
) {
|
||||
$erkennungSeit = $this->ReadAttributeInteger('PhasenerkennungSeit');
|
||||
if ($erkennungSeit <= 0) {
|
||||
$this->sendeLadestromAnGeraet(self::PHASENERKENNUNGSSTROM);
|
||||
$erkennungSeit = time();
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', $erkennungSeit);
|
||||
} elseif ((time() - $erkennungSeit)
|
||||
>= $this->ReadPropertyInteger('Phasenerkennungszeit')
|
||||
) {
|
||||
$bekanntePhasenzahl = LadestationRegler::erkennePhasenzahlAusLeistung(
|
||||
(float) $rohstatus['Istleistung_W'],
|
||||
$this->ReadAttributeInteger('LetzterGesetzterStrom')
|
||||
);
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
}
|
||||
} else {
|
||||
$this->WriteAttributeInteger('PhasenerkennungSeit', 0);
|
||||
}
|
||||
}
|
||||
|
||||
$status = LadestationRegler::normalisiereStatus(
|
||||
$rohstatus,
|
||||
$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')
|
||||
$warVerbunden,
|
||||
$this->ReadAttributeBoolean('ZustandFahrzeugGeladen'),
|
||||
$this->ReadAttributeInteger('LetzterGesetzterStrom') >= 6,
|
||||
$this->ReadAttributeBoolean('LadeleistungSeitFreigabeErkannt'),
|
||||
$bekanntePhasenzahl
|
||||
);
|
||||
if (!$status['FahrzeugVerbunden']) {
|
||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||
} elseif ($status['Ladestrom_A'] >= 3.5) {
|
||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', true);
|
||||
}
|
||||
if (!$status['FahrzeugVerbunden']
|
||||
|| $status['Phasenzahl'] !== LadestationRegler::PHASEN_UNBEKANNT
|
||||
) {
|
||||
$this->aktualisiereFahrzeugstromgrenze($status);
|
||||
}
|
||||
|
||||
foreach ($status as $ident => $wert) {
|
||||
$this->setzeZustand($ident, $wert);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, int|float|bool> $status */
|
||||
private function aktualisiereFahrzeugstromgrenze(array $status): void
|
||||
{
|
||||
$konfigurierterMaximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||
if (!$status['FahrzeugVerbunden']) {
|
||||
$this->WriteAttributeInteger('ErkannterMaximalstrom', $konfigurierterMaximalstrom);
|
||||
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $konfigurierterMaximalstrom);
|
||||
$this->setzeFahrzeugstromKandidatZurueck();
|
||||
return;
|
||||
}
|
||||
|
||||
$kandidat = LadestationRegler::ermittleFahrzeugMaximalstrom(
|
||||
$this->ReadAttributeInteger('LetzterGesetzterStrom'),
|
||||
(float) $status['Ladestrom_A'],
|
||||
$konfigurierterMaximalstrom
|
||||
);
|
||||
if ($kandidat === null
|
||||
|| $kandidat >= $this->ReadAttributeInteger('ErkannterMaximalstrom')
|
||||
) {
|
||||
$this->setzeFahrzeugstromKandidatZurueck();
|
||||
return;
|
||||
}
|
||||
|
||||
$jetzt = time();
|
||||
if ($this->ReadAttributeInteger('FahrzeugstromKandidat') !== $kandidat) {
|
||||
$this->WriteAttributeInteger('FahrzeugstromKandidat', $kandidat);
|
||||
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', $jetzt);
|
||||
return;
|
||||
}
|
||||
if (($jetzt - $this->ReadAttributeInteger('FahrzeugstromKandidatSeit'))
|
||||
< $this->ReadPropertyInteger('FahrzeugstromErkennungszeit')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->WriteAttributeInteger('ErkannterMaximalstrom', $kandidat);
|
||||
$this->WriteAttributeInteger('LeistungswechselGesperrtBis', 0);
|
||||
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $kandidat);
|
||||
$this->setzeFahrzeugstromKandidatZurueck();
|
||||
}
|
||||
|
||||
private function setzeFahrzeugstromKandidatZurueck(): void
|
||||
{
|
||||
$this->WriteAttributeInteger('FahrzeugstromKandidat', 0);
|
||||
$this->WriteAttributeInteger('FahrzeugstromKandidatSeit', 0);
|
||||
}
|
||||
|
||||
/** @param list<int> $angebot */
|
||||
private function bestimmeWirksameSollleistung(array $angebot): int
|
||||
{
|
||||
@@ -317,7 +561,15 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
if (in_array($sollleistung, $angebot, true)) {
|
||||
return $sollleistung;
|
||||
}
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$ersatz = 0;
|
||||
foreach ($angebot as $leistungswert) {
|
||||
if ($leistungswert > $sollleistung) {
|
||||
break;
|
||||
}
|
||||
$ersatz = $leistungswert;
|
||||
}
|
||||
return $ersatz;
|
||||
}
|
||||
|
||||
if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) {
|
||||
@@ -333,12 +585,61 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$strom = $leistung === 0
|
||||
? 0
|
||||
: LadestationRegler::stromFuerLeistung($leistung, $phasenzahl);
|
||||
if ($strom === $this->ReadAttributeInteger('LetzterGesetzterStrom')) {
|
||||
$bisherigeLeistung = $this->ReadAttributeInteger('LetzteGesetzteLeistung');
|
||||
$stromUnveraendert = $strom === $this->ReadAttributeInteger('LetzterGesetzterStrom');
|
||||
|
||||
if ($stromUnveraendert && $leistung === $bisherigeLeistung) {
|
||||
return;
|
||||
}
|
||||
if (!$stromUnveraendert) {
|
||||
$this->sendeLadestromAnGeraet($strom);
|
||||
}
|
||||
if ($leistung === $bisherigeLeistung) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->ReadAttributeBoolean('ZustandFahrzeugVerbunden')) {
|
||||
$leistungswechselGesperrtBis = 0;
|
||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||
} else {
|
||||
$jetzt = time();
|
||||
$wechselzeit = $this->ReadPropertyInteger('ZeitZwischenZustandswechseln');
|
||||
$leistungswechselGesperrtBis = $wechselzeit > 0
|
||||
? $jetzt + ($wechselzeit * 60)
|
||||
: 0;
|
||||
|
||||
if ($bisherigeLeistung <= 0 && $leistung > 0) {
|
||||
$mindestzeit = $this->ReadPropertyInteger('Mindesteinschaltdauer');
|
||||
$this->WriteAttributeInteger(
|
||||
'AusschaltenGesperrtBis',
|
||||
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
|
||||
);
|
||||
$this->WriteAttributeInteger('EinschaltenGesperrtBis', 0);
|
||||
} elseif ($bisherigeLeistung > 0 && $leistung <= 0) {
|
||||
$mindestzeit = $this->ReadPropertyInteger('Mindestausschaltdauer');
|
||||
$this->WriteAttributeInteger(
|
||||
'EinschaltenGesperrtBis',
|
||||
$mindestzeit > 0 ? $jetzt + ($mindestzeit * 60) : 0
|
||||
);
|
||||
$this->WriteAttributeInteger('AusschaltenGesperrtBis', 0);
|
||||
}
|
||||
}
|
||||
|
||||
$this->WriteAttributeInteger('LetzteGesetzteLeistung', $leistung);
|
||||
$this->WriteAttributeInteger(
|
||||
'LeistungswechselGesperrtBis',
|
||||
$leistungswechselGesperrtBis
|
||||
);
|
||||
}
|
||||
|
||||
private function sendeLadestromAnGeraet(int $strom): void
|
||||
{
|
||||
$this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false);
|
||||
$this->setzeFahrzeugstromKandidatZurueck();
|
||||
|
||||
$transport = new LadestationTransport(
|
||||
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage)
|
||||
fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage, true)
|
||||
);
|
||||
$transport->ladestromSetzen(
|
||||
$this->ReadPropertyInteger('Geraetetyp'),
|
||||
@@ -351,16 +652,21 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
/**
|
||||
* @param array<string, mixed> $anfrage
|
||||
*/
|
||||
private function fuehreHttpAnfrageAus(array $anfrage): string
|
||||
private function fuehreHttpAnfrageAus(
|
||||
array $anfrage,
|
||||
bool $alsGeraetebefehl = false
|
||||
): string
|
||||
{
|
||||
$protokoll = [
|
||||
'Methode' => $anfrage['Methode'],
|
||||
'URL' => $anfrage['URL'],
|
||||
];
|
||||
$this->setzeSichtbareVariable(
|
||||
'LetzterGeraetebefehl',
|
||||
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
|
||||
);
|
||||
if ($alsGeraetebefehl) {
|
||||
$protokoll = [
|
||||
'Methode' => $anfrage['Methode'],
|
||||
'URL' => $anfrage['URL'],
|
||||
];
|
||||
$this->setzeSichtbareVariable(
|
||||
'LetzterGeraetebefehl',
|
||||
json_encode($protokoll, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('Testmodus')) {
|
||||
$url = (string) $anfrage['URL'];
|
||||
@@ -564,6 +870,23 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
return is_array($zuordnung) ? $zuordnung : [];
|
||||
}
|
||||
|
||||
private function starteRegeluebergang(): void
|
||||
{
|
||||
if ($this->zugeordneteManagerIDs() === []) {
|
||||
$this->beendeRegeluebergang();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->WriteAttributeBoolean('RegeluebergangAktiv', true);
|
||||
$this->SetTimerInterval('RegeluebergangTimeout', self::REGELUEBERGANG_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private function beendeRegeluebergang(): void
|
||||
{
|
||||
$this->WriteAttributeBoolean('RegeluebergangAktiv', false);
|
||||
$this->SetTimerInterval('RegeluebergangTimeout', 0);
|
||||
}
|
||||
|
||||
private function pruefeKonfiguration(): void
|
||||
{
|
||||
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $property) {
|
||||
@@ -571,11 +894,27 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
throw new InvalidArgumentException($property . ' muss mindestens 0 sein.');
|
||||
}
|
||||
}
|
||||
foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) {
|
||||
foreach ([
|
||||
'Meldeintervall',
|
||||
'VorgabeTimeout',
|
||||
'Abfrageintervall',
|
||||
'Phasenerkennungszeit',
|
||||
'FahrzeugstromErkennungszeit',
|
||||
] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) <= 0) {
|
||||
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
|
||||
}
|
||||
}
|
||||
foreach ([
|
||||
'ZeitZwischenZustandswechseln',
|
||||
'Mindesteinschaltdauer',
|
||||
'Mindestausschaltdauer',
|
||||
] as $property) {
|
||||
if ($this->ReadPropertyInteger($property) < 0) {
|
||||
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
|
||||
}
|
||||
}
|
||||
|
||||
$maximalstrom = $this->ReadPropertyInteger('MaximalerLadestrom');
|
||||
if ($maximalstrom < 6 || $maximalstrom > 32) {
|
||||
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
|
||||
@@ -611,28 +950,31 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
|
||||
private function aktualisiereVariablen(): void
|
||||
{
|
||||
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
|
||||
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
|
||||
$this->EnableAction('Ladefreigabe');
|
||||
$this->EnableAction('Solarladen');
|
||||
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
|
||||
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
|
||||
} else {
|
||||
$this->entferneVariable('Ladefreigabe');
|
||||
$this->entferneVariable('Solarladen');
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('DiagnosevariablenAnzeigen')) {
|
||||
$this->registriereVerbraucherDiagnose();
|
||||
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200);
|
||||
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210);
|
||||
$this->RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200);
|
||||
$this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 210);
|
||||
$this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 220);
|
||||
$this->RegisterVariableInteger(
|
||||
'FahrzeugMaximalstrom',
|
||||
'Erkannter Fahrzeug-Maximalstrom',
|
||||
'',
|
||||
230
|
||||
);
|
||||
$this->SetValue(
|
||||
'FahrzeugMaximalstrom',
|
||||
$this->ReadAttributeInteger('ErkannterMaximalstrom')
|
||||
);
|
||||
$this->SetValue(
|
||||
'LeistungsangebotDiagnose',
|
||||
$this->ReadAttributeString('Leistungsangebot')
|
||||
);
|
||||
foreach (self::DIAGNOSE_VARIABLEN as $ident) {
|
||||
if (!in_array($ident, ['LetzterGeraetebefehl', 'LeistungsangebotDiagnose'], true)) {
|
||||
if (!in_array($ident, [
|
||||
'LetzterGeraetebefehl',
|
||||
'LeistungsangebotDiagnose',
|
||||
'FahrzeugMaximalstrom',
|
||||
], true)) {
|
||||
$this->setzeSichtbareVariable($ident, $this->leseZustand($ident));
|
||||
}
|
||||
}
|
||||
@@ -641,6 +983,24 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
$this->entferneVariable($ident);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->ReadPropertyBoolean('EinstellungenInVisu')) {
|
||||
$this->RegisterVariableBoolean('Ladefreigabe', 'Ladefreigabe', '~Switch', 150);
|
||||
$this->RegisterVariableBoolean('Solarladen', 'Solarladen', '~Switch', 160);
|
||||
$this->RegisterVariableFloat('Istleistung', 'Istleistung', '', 170);
|
||||
$this->RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 180);
|
||||
$this->EnableAction('Ladefreigabe');
|
||||
$this->EnableAction('Solarladen');
|
||||
$this->SetValue('Ladefreigabe', $this->ReadAttributeBoolean('ZustandLadefreigabe'));
|
||||
$this->SetValue('Solarladen', $this->ReadAttributeBoolean('ZustandSolarladen'));
|
||||
$this->SetValue('Istleistung', $this->ReadAttributeFloat('ZustandIstleistung'));
|
||||
$this->SetValue('Sollleistung', $this->ReadAttributeInteger('ZustandSollleistung'));
|
||||
} else {
|
||||
$this->entferneVariable('Ladefreigabe');
|
||||
$this->entferneVariable('Solarladen');
|
||||
$this->entferneVariable('Istleistung');
|
||||
$this->entferneVariable('Sollleistung');
|
||||
}
|
||||
}
|
||||
|
||||
private function entferneVariable(string $ident): void
|
||||
@@ -748,7 +1108,13 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
|
||||
|
||||
private function deaktiviereTimer(): void
|
||||
{
|
||||
foreach (['Statuszyklus', 'Meldezyklus', 'RueckmeldungVerzoegert', 'VorgabeTimeout'] as $timer) {
|
||||
foreach ([
|
||||
'Statuszyklus',
|
||||
'Meldezyklus',
|
||||
'RueckmeldungVerzoegert',
|
||||
'VorgabeTimeout',
|
||||
'RegeluebergangTimeout',
|
||||
] as $timer) {
|
||||
$this->SetTimerInterval($timer, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+632
-7
@@ -26,6 +26,13 @@
|
||||
"name": "LizenzPruefen",
|
||||
"caption": "Lizenz pruefen und binden",
|
||||
"onClick": "IPS_RequestAction($id, 'FormLizenzPruefen', $Lizenzcode);"
|
||||
},
|
||||
{
|
||||
"type": "Button",
|
||||
"name": "LizenzVerwalten",
|
||||
"caption": "Lizenz verwalten",
|
||||
"link": true,
|
||||
"onClick": "echo 'https://license.enelix.ch';"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -120,6 +127,24 @@
|
||||
],
|
||||
"values": []
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "PeakShavingTitel",
|
||||
"caption": "Peak Shaving am Netzanschlusspunkt"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EinspeisebegrenzungAktiv",
|
||||
"caption": "Anlagenweite Einspeisebegrenzung aktivieren"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "Einspeisegrenze",
|
||||
"caption": "Maximale Einspeisung der Gesamtanlage",
|
||||
"suffix": " W",
|
||||
"minimum": 0.0,
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "AutomatischeSuche",
|
||||
@@ -199,6 +224,387 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Anlagentopologie",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "NetzbezugEnergieVariableID",
|
||||
"caption": "Netzbezugszaehler"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "NetzbezugEnergiefaktor",
|
||||
"caption": "Faktor",
|
||||
"digits": 6
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "NetzeinspeisungEnergieVariableID",
|
||||
"caption": "Einspeisezaehler"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "NetzeinspeisungEnergiefaktor",
|
||||
"caption": "Faktor",
|
||||
"digits": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Energiezaehler werden in kWh erwartet; ueber den Faktor koennen beispielsweise Wh (0,001) umgerechnet werden."
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Die Wechselrichter-Tabelle beschreibt die AC-Wandler der Anlage. PV-Flaechen und Batteriespeicher verweisen darauf, damit gemeinsame Leistungsgrenzen und die elektrische Kopplung eindeutig sind. Bei einem Hybridgeraet verwenden PV und Batterie dieselbe Wechselrichter-ID; bei einem AC-gekoppelten Speicher wird dessen separater Batteriewechselrichter erfasst."
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenWechselrichter",
|
||||
"caption": "Wechselrichter / AC-Wandler",
|
||||
"rowCount": 4,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "130px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "Wechselrichter",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Typ",
|
||||
"name": "Typ",
|
||||
"width": "130px",
|
||||
"add": "pv",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "PV", "value": "pv"},
|
||||
{"caption": "Batterie", "value": "battery"},
|
||||
{"caption": "Hybrid", "value": "hybrid"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "AC Nennleistung",
|
||||
"name": "ACNennleistung_kW",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Istleistung",
|
||||
"name": "IstleistungVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Messfaktor",
|
||||
"name": "Istleistungsfaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 4}
|
||||
},
|
||||
{
|
||||
"caption": "Erzeugungsenergie",
|
||||
"name": "ErzeugungsenergieVariableID",
|
||||
"width": "160px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Energiefaktor",
|
||||
"name": "Erzeugungsenergiefaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 6}
|
||||
},
|
||||
{
|
||||
"caption": "PV-Begrenzung",
|
||||
"name": "BegrenzungVariableID",
|
||||
"width": "150px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Stellwert",
|
||||
"name": "Begrenzungsart",
|
||||
"width": "120px",
|
||||
"add": "percent",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "Prozent", "value": "percent"},
|
||||
{"caption": "Watt", "value": "watt"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenPVFlaechen",
|
||||
"caption": "PV-Flaechen",
|
||||
"rowCount": 6,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "120px",
|
||||
"add": "pv-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "PV-Flaeche",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "DC-Leistung",
|
||||
"name": "DCLeistung_kWp",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWp",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Neigung",
|
||||
"name": "Neigung_Grad",
|
||||
"width": "110px",
|
||||
"add": 30.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Grad",
|
||||
"minimum": 0.0,
|
||||
"maximum": 90.0,
|
||||
"digits": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Azimut",
|
||||
"name": "Azimut_Grad",
|
||||
"width": "110px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Grad",
|
||||
"minimum": -180.0,
|
||||
"maximum": 180.0,
|
||||
"digits": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Wechselrichter-ID",
|
||||
"name": "WechselrichterID",
|
||||
"width": "150px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "MPPT",
|
||||
"name": "MPPT",
|
||||
"width": "90px",
|
||||
"add": "",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Module",
|
||||
"name": "Modulanzahl",
|
||||
"width": "90px",
|
||||
"add": 0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"minimum": 0,
|
||||
"digits": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Modulleistung",
|
||||
"name": "Modulleistung_Wp",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " Wp",
|
||||
"minimum": 0.0,
|
||||
"digits": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Der Batterieeintrag beschreibt den Speicher selbst: Kapazitaet, Leistung, Ladezustand und Energiezaehler. Die Wechselrichter-ID ist nur die Verknuepfung zum zugehoerigen AC-Wandler und keine doppelte Batteriekonfiguration."
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "AnlagenBatterien",
|
||||
"caption": "Batteriespeicher",
|
||||
"rowCount": 4,
|
||||
"add": true,
|
||||
"delete": true,
|
||||
"sortable": true,
|
||||
"columns": [
|
||||
{
|
||||
"caption": "ID",
|
||||
"name": "ID",
|
||||
"width": "120px",
|
||||
"add": "bat-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Name",
|
||||
"name": "Name",
|
||||
"width": "auto",
|
||||
"add": "Batteriespeicher",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Nennkapazitaet",
|
||||
"name": "Nennkapazitaet_kWh",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWh",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Nutzkapazitaet",
|
||||
"name": "Nutzkapazitaet_kWh",
|
||||
"width": "150px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kWh",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Max. Laden",
|
||||
"name": "MaxLadeleistung_kW",
|
||||
"width": "130px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Max. Entladen",
|
||||
"name": "MaxEntladeleistung_kW",
|
||||
"width": "140px",
|
||||
"add": 0.0,
|
||||
"edit": {
|
||||
"type": "NumberSpinner",
|
||||
"suffix": " kW",
|
||||
"minimum": 0.0,
|
||||
"digits": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Wechselrichter-ID",
|
||||
"name": "WechselrichterID",
|
||||
"width": "150px",
|
||||
"add": "wr-1",
|
||||
"edit": {"type": "ValidationTextBox"}
|
||||
},
|
||||
{
|
||||
"caption": "Kopplung",
|
||||
"name": "Kopplung",
|
||||
"width": "130px",
|
||||
"add": "ac",
|
||||
"edit": {
|
||||
"type": "Select",
|
||||
"options": [
|
||||
{"caption": "AC", "value": "ac"},
|
||||
{"caption": "DC", "value": "dc"},
|
||||
{"caption": "Hybrid", "value": "hybrid"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Leistung",
|
||||
"name": "LeistungVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Messfaktor",
|
||||
"name": "Leistungsfaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 4}
|
||||
},
|
||||
{
|
||||
"caption": "Ladezustand",
|
||||
"name": "SOCVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Ladeenergie",
|
||||
"name": "LadeenergieVariableID",
|
||||
"width": "140px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Entladeenergie",
|
||||
"name": "EntladeenergieVariableID",
|
||||
"width": "150px",
|
||||
"add": 0,
|
||||
"edit": {"type": "SelectVariable"}
|
||||
},
|
||||
{
|
||||
"caption": "Energiefaktor",
|
||||
"name": "Energiefaktor",
|
||||
"width": "110px",
|
||||
"add": 1.0,
|
||||
"edit": {"type": "NumberSpinner", "digits": 6}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Erweiterte Einstellungen",
|
||||
@@ -251,14 +657,177 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Energieaufzeichnung",
|
||||
"expanded": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergieaufzeichnungAktiv",
|
||||
"caption": "Leistungs- und Energiewerte automatisch aufzeichnen"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "PV und Batterie werden aus der Anlagentopologie summiert. Der Hausverbrauch wird aus PV + Netz - Batterie berechnet. Netz: positiv Bezug; Batterie: positiv Laden."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "MesswerteAnzeigen",
|
||||
"caption": "Einzelne Leistungs- und Energiewerte unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "LeistungsaufzeichnungMinuten",
|
||||
"caption": "Leistung verdichten",
|
||||
"options": [
|
||||
{"caption": "1 Minute", "value": 1},
|
||||
{"caption": "5 Minuten", "value": 5},
|
||||
{"caption": "1 Stunde", "value": 60}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "LeistungLoeschenMonate",
|
||||
"caption": "Leistung loeschen nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten (0 = nie)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "EnergieaufzeichnungMinuten",
|
||||
"caption": "Energie verdichten",
|
||||
"options": [
|
||||
{"caption": "1 Minute", "value": 1},
|
||||
{"caption": "5 Minuten", "value": 5},
|
||||
{"caption": "1 Stunde", "value": 60}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "EnergieVerdichtenMonate",
|
||||
"caption": "Auf Tageswerte verdichten nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "EnergieLoeschenMonate",
|
||||
"caption": "Energie loeschen nach",
|
||||
"minimum": 0,
|
||||
"suffix": " Monaten (0 = nie)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergyPieAnzeigen",
|
||||
"caption": "Energy Pie unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergiediagrammeAnzeigen",
|
||||
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "RowLayout",
|
||||
"items": [
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "DiagrammPVModus",
|
||||
"caption": "PV in Diagrammen",
|
||||
"options": [
|
||||
{"caption": "Summe", "value": 0},
|
||||
{"caption": "Einzelne Anlagen", "value": 1},
|
||||
{"caption": "Summe und einzelne Anlagen", "value": 2}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "DiagrammBatterieModus",
|
||||
"caption": "Batterien in Diagrammen",
|
||||
"options": [
|
||||
{"caption": "Summe", "value": 0},
|
||||
{"caption": "Einzelne Speicher", "value": 1},
|
||||
{"caption": "Summe und einzelne Speicher", "value": 2}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "LadestationenSeparatAnzeigen",
|
||||
"caption": "Ladestationen separat in Energiefluss und Diagrammen anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "VerbraucherSeparatAnzeigen",
|
||||
"caption": "Uebrige Verbraucher separat in Energiefluss und Diagrammen anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Bestehende Energy Pies, Diagramme und Energiefluesse bleiben bei Apply unveraendert. Fuer eine Neuerzeugung den jeweiligen Schalter ausschalten, Apply ausfuehren, wieder einschalten und erneut Apply ausfuehren."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "FunFactsAnzeigen",
|
||||
"caption": "Energy Facts unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "EnergieflussAnzeigen",
|
||||
"caption": "Energiefluss unter dem Manager anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "Select",
|
||||
"name": "EnergieflussLeistungseinheit",
|
||||
"caption": "Leistungseinheit im Energiefluss",
|
||||
"options": [
|
||||
{"caption": "Watt (W)", "value": 0},
|
||||
{"caption": "Kilowatt (kW)", "value": 1}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "Prognose / Forecast",
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "PrognoseAnschluss",
|
||||
"caption": "Konfiguration als JSON"
|
||||
"type": "CheckBox",
|
||||
"name": "PrognoseAktiv",
|
||||
"caption": "Prognosen aktivieren",
|
||||
"onChange": "IPS_RequestAction($id, 'FormPrognoseAktiv', $PrognoseAktiv);"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"name": "PrognoseLizenzInformation",
|
||||
"caption": "Prognoselizenz wird geprueft."
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Forecast verwendet dieselben Messquellen aus der Anlagentopologie und den daraus berechneten Hausverbrauch."
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "NetzfahrplanAktiv",
|
||||
"caption": "Intelligenten Netzfahrplan zur Vermeidung von PV-Abregelung verwenden"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrognoseSendeintervall",
|
||||
"caption": "Aktuelle Messwerte an den Prognosedienst senden alle",
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"suffix": " Sekunden"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -266,6 +835,50 @@
|
||||
"type": "ExpansionPanel",
|
||||
"caption": "SDL / VGT",
|
||||
"items": [
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "SDLAktiv",
|
||||
"caption": "SDL aktiv",
|
||||
"onChange": "IPS_RequestAction($id, 'FormSDLAktiv', $SDLAktiv);"
|
||||
},
|
||||
{
|
||||
"type": "ExpansionPanel",
|
||||
"name": "SDLKonfiguration",
|
||||
"caption": "SDL / Regelenergie Messung",
|
||||
"visible": false,
|
||||
"items": [
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "SDLLeistungVariableID",
|
||||
"caption": "SDL Istleistung"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "SDLLeistungsfaktor",
|
||||
"caption": "SDL Leistungsfaktor nach Watt",
|
||||
"digits": 3
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "SDLSOCVariableID",
|
||||
"caption": "SDL SOC in Prozent (optional)"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "SDLEnergieflussAnzeigen",
|
||||
"caption": "SDL mit SOC im Energiefluss anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "CheckBox",
|
||||
"name": "SDLDiagrammeAnzeigen",
|
||||
"caption": "SDL mit SOC in den Diagrammen anzeigen"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Nach Skalierung: positiv = Laden / Bezug, negativ = Entladen / Abgabe. SDL muss separat gemessen sein und darf nicht bereits in der normalen Batterieleistung enthalten sein. Die Hauslast fuer Prognosen wird unabhaengig von den Anzeigeoptionen korrigiert."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "SDLAnschluss",
|
||||
@@ -278,9 +891,21 @@
|
||||
"caption": "Stoerueberwachung",
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "StoermeldeAnschluss",
|
||||
"caption": "Konfiguration als JSON"
|
||||
"type": "CheckBox",
|
||||
"name": "StoerueberwachungAktiv",
|
||||
"caption": "Stoerungen sicher an license.enelix.ch uebertragen"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "StoerungsSendeintervall",
|
||||
"caption": "Heartbeat-Intervall",
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"suffix": " Sekunden"
|
||||
},
|
||||
{
|
||||
"type": "Label",
|
||||
"caption": "Die Freischaltung als jaehrlich erneuerbare Lizenz ist vorbereitet und wird spaeter aktiviert."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -296,6 +921,6 @@
|
||||
{"code": 102, "icon": "active", "caption": "Aktiv"},
|
||||
{"code": 201, "icon": "inactive", "caption": "Netzleistungsmessung fehlt oder ist veraltet"},
|
||||
{"code": 202, "icon": "error", "caption": "Konfiguration ungueltig"},
|
||||
{"code": 203, "icon": "error", "caption": "Lizenz fehlt oder ist abgelaufen"}
|
||||
{"code": 203, "icon": "error", "caption": "Lizenz nicht freigegeben oder Verbraucherkontingent ueberschritten"}
|
||||
]
|
||||
}
|
||||
|
||||
+2247
-81
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
} catch (Throwable $fehler) {
|
||||
@@ -150,7 +151,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->regelzyklus(true);
|
||||
return;
|
||||
@@ -208,7 +209,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->regelzyklus(false);
|
||||
}
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
@@ -290,7 +291,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
|
||||
if ($letzteVorgabe <= 0
|
||||
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
$ziel = 0;
|
||||
@@ -301,7 +302,7 @@ class VerbraucherPufferspeicher extends IPSModule implements VerbraucherSchnitts
|
||||
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$ziel = (int) $this->leseZustand('Sollleistung');
|
||||
if (!in_array($ziel, $angebot, true)) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$ziel = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,69 @@ Energiemanagement, Manager und steuerbare Verbraucher fuer IP-Symcon.
|
||||
|
||||
## Status
|
||||
|
||||
Das Repository befindet sich im Aufbau. Der gemeinsame Nachrichtenvertrag, die
|
||||
Verbraucherbasis, der Manager, der mehrstufige Warmwassererwaermer, der
|
||||
1-stufige Verbraucher und die Ladestation Stand-Alone sind implementiert. Die
|
||||
weiteren Module sind als Diskussionsentwürfe dokumentiert.
|
||||
Version 0.1 Build 1 ist die erste Beta fuer kontrollierte Anlagen- und
|
||||
Feldtests. Der gemeinsame Nachrichtenvertrag, die Verbraucherbasis, der Manager
|
||||
und alle unten aufgefuehrten Module sind implementiert. Produktiver Einsatz
|
||||
setzt eine anlagenspezifische Pruefung, Sicherung und Rueckfallplanung voraus.
|
||||
|
||||
## Geplante Module
|
||||
## Module
|
||||
|
||||
- Manager (implementiert)
|
||||
- Batterie (implementiert)
|
||||
- Wassererwaermer (implementiert)
|
||||
- Pufferspeicher
|
||||
- Pufferspeicher (implementiert)
|
||||
- Verbraucher 1-Stufig (implementiert)
|
||||
- Waermepumpe (implementiert)
|
||||
- Ladestation Stand-Alone (implementiert)
|
||||
- Ladestation Gateway
|
||||
- Easee Gateway
|
||||
- Ladestation Gateway (implementiert)
|
||||
- Easee Gateway (implementiert)
|
||||
|
||||
Die Properties, Variablen, Zustandsdaten und offenen Punkte stehen in der
|
||||
[Modulübersicht](docs/module/README.md). Gemeinsame Felder werden dort nicht
|
||||
abweichend neu definiert, sondern verweisen auf den zentralen Vertrag.
|
||||
|
||||
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt es keine Manager-ID-Property.
|
||||
Die Verbraucher kommunizieren mit dem Manager ueber den gemeinsamen Vertrag
|
||||
in `libs/`. Der Manager waehlt die Verbraucher aus; in einem Verbraucher gibt
|
||||
es keine Manager-ID-Property.
|
||||
|
||||
## Betriebsartabhaengige Leistungsangebote
|
||||
|
||||
Der Nachrichtenvertrag `4.0` uebermittelt in beiden Richtungen verpflichtend
|
||||
die aktuelle `Betriebsart`. Zulaessige Werte sind `PV` fuer Solarbetrieb und
|
||||
`Peak` fuer die Lastspitzenbegrenzung. Dadurch kann jeder Verbraucher fuer
|
||||
beide Betriebsarten unterschiedliche PowerSteps melden.
|
||||
|
||||
Ein Betriebsartwechsel wird synchronisiert:
|
||||
|
||||
1. Der Manager bestimmt die neue Betriebsart.
|
||||
2. Er sendet sie mit `Sollleistung_W: null` an alle aktiven Verbraucher.
|
||||
3. `null` ist nur eine Ankuendigung und kein Schaltbefehl.
|
||||
4. Jeder Verbraucher berechnet sein zustandsabhaengiges Leistungsangebot neu
|
||||
und meldet es mit derselben Betriebsart zurueck.
|
||||
5. Der Manager verteilt konkrete Sollleistungen an alle bereits
|
||||
synchronisierten Verbraucher.
|
||||
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher bleiben von
|
||||
der Verteilung ausgeschlossen und werden als Stoerung ausgewiesen.
|
||||
|
||||
Der Manager verwendet damit nie ein PV-Angebot fuer Peak oder umgekehrt. Ein
|
||||
nicht antwortender Verbraucher blockiert die Regelung der uebrigen Anlage
|
||||
nicht; sein aktueller Einfluss ist bereits in der Netzleistungsmessung
|
||||
enthalten. Eine technische Schaltsperre oder eine Mindestzeit kann das Angebot
|
||||
weiterhin auf die aktuell gehaltene Leistung begrenzen.
|
||||
|
||||
| Verbraucher | PV-Angebot | Peak-Angebot |
|
||||
| --- | --- | --- |
|
||||
| Verbraucher 1-Stufig | Normal `[0, Nennleistung]`; bei faelliger Tagesmindestlaufzeit `[Nennleistung]` | Normal `[0]`; bei faelliger Tagesmindestlaufzeit konfigurierbar `[0, Nennleistung]` oder `[Nennleistung]` |
|
||||
| Ladestation Stand-Alone | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||
| Ladestation Gateway | Mit Solarladen `[0, ...Ladestufen]`, sonst maximale Ladeleistung | Mit Solarladen `[0]`, sonst `[0, ...Ladestufen]` |
|
||||
| Warmwassererwaermer | Unter Mindesttemperatur maximale Stufe, sonst zustandsabhaengig | Unter wirksamer Mindesttemperatur `[0, ...Leistungsstufen]`, sonst `[0]` |
|
||||
| Pufferspeicher | Unter Einschaltschwelle `[0, ...Leistungsstufen]`, sonst `[0]` | Unabhaengig vom Zustand `[0]` |
|
||||
| Batterie | SoC-abhaengiges Lade-/Entladeraster | SoC-, Reserve- und netzabhaengiges Angebot |
|
||||
|
||||
Die vollstaendigen Nachrichtenfelder und JSON-Beispiele stehen in der
|
||||
[Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md). Hintergrund,
|
||||
Alternativen und Folgen beschreibt
|
||||
[ADR 0004](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md).
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
@@ -60,14 +101,21 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- [Changelog](CHANGELOG.md)
|
||||
- [Zentrale Liste offener Teamentscheidungen](docs/Offene-Punkte.md)
|
||||
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
|
||||
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
|
||||
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
|
||||
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
|
||||
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
|
||||
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
|
||||
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
|
||||
- [Batteriemodul](docs/module/Batterie/README.md)
|
||||
- [Verbraucher-1-Stufig-Modul](docs/module/Verbraucher-1-Stufig/README.md)
|
||||
- [Ladestation-Stand-Alone-Modul](docs/module/Ladestation-Stand-Alone/README.md)
|
||||
- [Ladestation-Gateway-Modul](docs/module/Ladestation-Gateway/README.md)
|
||||
- [Easee-Gateway-Modul](docs/module/Easee-Gateway/README.md)
|
||||
- [Easee-Gateway-Schnittstelle](docs/Schnittstelle-Easee-Gateway.md)
|
||||
- [Migration Boiler x-Stufig](docs/migration/Boiler-x-Stufig.md)
|
||||
- [Migration Verbraucher 1-Stufig](docs/migration/Verbraucher-1-Stufig.md)
|
||||
- [Migration Batterie](docs/migration/Batterie.md)
|
||||
|
||||
@@ -118,6 +118,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
$this->registriereMeldungen();
|
||||
|
||||
try {
|
||||
@@ -158,7 +159,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
@@ -214,7 +215,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->planeMeldung();
|
||||
return;
|
||||
@@ -254,7 +255,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
true
|
||||
)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
$aktiv = (bool) $this->GetValue('Aktiv');
|
||||
@@ -474,7 +475,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
if ($letzteVorgabe <= 0
|
||||
|| $jetzt - $letzteVorgabe >= $this->ReadPropertyInteger('VorgabeTimeout')
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
}
|
||||
@@ -528,7 +529,7 @@ class VerbraucherEinStufig extends IPSModule implements VerbraucherSchnittstelle
|
||||
$this->WriteAttributeString('LaufzeitTag', date('Y-m-d', $jetzt));
|
||||
$this->WriteAttributeInteger('LaufzeitStandZeit', $jetzt);
|
||||
$this->setzeZustand('Tageslaufzeit', 0);
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->setzeZustand('Rueckmeldefehler', false);
|
||||
$this->WriteAttributeBoolean('Initialisiert', true);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
parent::ApplyChanges();
|
||||
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
$this->registriereMeldungen();
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
@@ -172,7 +173,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
case 'Aktiv':
|
||||
$this->SetValue('Aktiv', (bool) $wert);
|
||||
if (!(bool) $wert) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
$this->aktualisiere(true);
|
||||
@@ -230,7 +231,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $this->leseLeistungsangebot(), true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
$this->planeMeldung();
|
||||
return;
|
||||
@@ -286,7 +287,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
$this->WriteAttributeInteger('Laufbeginn', $laeuft ? $jetzt : 0);
|
||||
$this->schalte(WaermepumpenRegler::STEUERZUSTAND_NORMAL, $jetzt);
|
||||
$steuerzustand = WaermepumpenRegler::STEUERZUSTAND_NORMAL;
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
$this->aktualisiereSperrerholung($laeuft, $jetzt);
|
||||
@@ -425,11 +426,6 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
}
|
||||
|
||||
$pv = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PV;
|
||||
if ($laeuft && $restMindestlaufzeit > 0) {
|
||||
return $pv
|
||||
? WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG
|
||||
: WaermepumpenRegler::STEUERZUSTAND_NORMAL;
|
||||
}
|
||||
if (!(bool) $this->leseZustand('SollwertGueltig')) {
|
||||
return WaermepumpenRegler::STEUERZUSTAND_NORMAL;
|
||||
}
|
||||
@@ -440,6 +436,9 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
? WaermepumpenRegler::STEUERZUSTAND_ERHOEHUNG
|
||||
: WaermepumpenRegler::STEUERZUSTAND_NORMAL;
|
||||
}
|
||||
if ($laeuft && $restMindestlaufzeit > 0) {
|
||||
return WaermepumpenRegler::STEUERZUSTAND_NORMAL;
|
||||
}
|
||||
|
||||
return $sollleistung === 0 && $sperrenZulaessig
|
||||
? WaermepumpenRegler::STEUERZUSTAND_SPERRE
|
||||
@@ -830,7 +829,7 @@ class VerbraucherWaermepumpe extends IPSModule implements VerbraucherSchnittstel
|
||||
$ablauf = $this->ReadAttributeInteger('LetzteVorgabeZeit')
|
||||
+ $this->ReadPropertyInteger('VorgabeTimeout');
|
||||
if ($this->ReadAttributeInteger('LetzteVorgabeZeit') <= 0 || $jetzt >= $ablauf) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->SetTimerInterval('VorgabeTimeout', 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,20 @@
|
||||
"minimum": 0.1,
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "TemperaturUntergrenze",
|
||||
"caption": "Untertemperaturgrenze",
|
||||
"suffix": " °C",
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "TemperaturObergrenze",
|
||||
"caption": "Übertemperaturgrenze",
|
||||
"suffix": " °C",
|
||||
"digits": 1
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
"name": "Zeitplan",
|
||||
|
||||
@@ -56,6 +56,8 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
$this->RegisterPropertyString('Zeitplan', '[]');
|
||||
$this->RegisterPropertyInteger('LastwechselSperrzeit', 5);
|
||||
$this->RegisterPropertyInteger('TemperaturMaxAlter', 30);
|
||||
$this->RegisterPropertyFloat('TemperaturUntergrenze', 0.0);
|
||||
$this->RegisterPropertyFloat('TemperaturObergrenze', 100.0);
|
||||
$this->RegisterPropertyFloat('Hysterese', 5.0);
|
||||
$this->RegisterPropertyBoolean('LegionellenfunktionAktiv', true);
|
||||
$this->RegisterPropertyInteger('LegionellenMinimalintervallTage', 4);
|
||||
@@ -120,6 +122,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
|
||||
$this->initialisiereTemperaturen();
|
||||
$this->aktualisiereVariablen();
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
|
||||
try {
|
||||
$this->pruefeKonfiguration();
|
||||
@@ -217,7 +220,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
if ((bool) $this->leseZustand('SollwertGueltig')
|
||||
&& !in_array((int) $this->leseZustand('Sollleistung'), $angebot, true)
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$this->regelzyklus(false);
|
||||
}
|
||||
$this->SetTimerInterval('RueckmeldungVerzoegert', 100);
|
||||
@@ -262,7 +265,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
if ($letzteVorgabe <= 0
|
||||
|| $jetzt - $letzteVorgabe > $this->ReadPropertyInteger('VorgabeTimeout')
|
||||
) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
$ziel = 0;
|
||||
@@ -274,7 +277,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
} elseif ((bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$ziel = (int) $this->leseZustand('Sollleistung');
|
||||
if (!in_array($ziel, $angebot, true)) {
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->verwerfeSollwert();
|
||||
$ziel = 0;
|
||||
}
|
||||
}
|
||||
@@ -537,6 +540,10 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
if (!$temperaturGueltig) {
|
||||
$stoerungen[] = 'Boilertemperatur fehlt oder ist veraltet.';
|
||||
}
|
||||
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
|
||||
if ($untertemperatur) {
|
||||
$stoerungen[] = 'Untertemperatur erkannt.';
|
||||
}
|
||||
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
|
||||
if ($uebertemperatur) {
|
||||
$stoerungen[] = 'Uebertemperatur erkannt.';
|
||||
@@ -555,19 +562,18 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
}
|
||||
}
|
||||
|
||||
private function istUntertemperatur(bool $temperaturGueltig): bool
|
||||
{
|
||||
return $temperaturGueltig
|
||||
&& (float) $this->GetValue('Boilertemperatur')
|
||||
< $this->ReadPropertyFloat('TemperaturUntergrenze');
|
||||
}
|
||||
|
||||
private function istUebertemperatur(bool $temperaturGueltig): bool
|
||||
{
|
||||
if (!$temperaturGueltig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$grenze = (float) $this->GetValue('Maximaltemperatur');
|
||||
if ($this->ReadPropertyBoolean('LegionellenfunktionAktiv')) {
|
||||
$grenze = max($grenze, $this->ReadAttributeFloat('ZustandLegionellentemperatur'));
|
||||
}
|
||||
|
||||
return (float) $this->GetValue('Boilertemperatur')
|
||||
> $grenze + $this->ReadPropertyFloat('Hysterese');
|
||||
return $temperaturGueltig
|
||||
&& (float) $this->GetValue('Boilertemperatur')
|
||||
> $this->ReadPropertyFloat('TemperaturObergrenze');
|
||||
}
|
||||
|
||||
private function sendeVerbraucherdaten(): void
|
||||
@@ -592,6 +598,7 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
{
|
||||
$temperaturGueltig = (bool) $this->leseZustand('TemperaturGueltig');
|
||||
$schaltfehler = $this->ReadAttributeString('Schaltfehler');
|
||||
$untertemperatur = $this->istUntertemperatur($temperaturGueltig);
|
||||
$uebertemperatur = $this->istUebertemperatur($temperaturGueltig);
|
||||
|
||||
return [
|
||||
@@ -655,6 +662,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
'Einheit' => '',
|
||||
'Text' => $temperaturGueltig ? '' : 'Boilertemperatur fehlt oder ist veraltet.',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Untertemperatur',
|
||||
'Art' => 'Stoerung',
|
||||
'Wert' => $untertemperatur,
|
||||
'Einheit' => '',
|
||||
'Text' => $untertemperatur ? 'Untertemperatur erkannt.' : '',
|
||||
],
|
||||
[
|
||||
'Kennung' => 'Uebertemperatur',
|
||||
'Art' => 'Stoerung',
|
||||
@@ -745,6 +759,13 @@ class VerbraucherWarmwassererwaermer extends IPSModule implements VerbraucherSch
|
||||
if ($this->ReadPropertyFloat('Hysterese') <= 0) {
|
||||
throw new InvalidArgumentException('Hysterese muss groesser als 0 sein.');
|
||||
}
|
||||
if ($this->ReadPropertyFloat('TemperaturUntergrenze')
|
||||
>= $this->ReadPropertyFloat('TemperaturObergrenze')
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
'Die Temperaturuntergrenze muss kleiner als die Temperaturobergrenze sein.'
|
||||
);
|
||||
}
|
||||
$minimum = (float) $this->GetValue('Mindesttemperatur');
|
||||
$maximum = (float) $this->GetValue('Maximaltemperatur');
|
||||
$legionellen = (float) $this->ReadAttributeFloat('ZustandLegionellentemperatur');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "belevo/enelix-ems",
|
||||
"description": "Energiemanagement und steuerbare Verbraucher fuer IP-Symcon",
|
||||
"type": "library",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
|
||||
+47
-21
@@ -1,34 +1,60 @@
|
||||
# Obere Anschlüsse des Managers
|
||||
|
||||
> Status: Lizenzierung implementiert; die übrigen Anbieterformate sind noch
|
||||
> nicht festgelegt.
|
||||
> Status: Lizenzierung, Prognose und Stoerueberwachung sind an
|
||||
> `license.enelix.ch` angebunden. SDL/VGT bleibt als optionaler Anschluss
|
||||
> vorbereitet.
|
||||
|
||||
Prognose, SDL/VGT und Stoerueberwachung sind optional. Die Lizenzierung ist fuer
|
||||
den Betrieb des Managers erforderlich. Sie werden im Manager konfiguriert und erzeugen
|
||||
keine Abhängigkeit zwischen Enelix EMS und Enelix Utils.
|
||||
Die Anschluesse werden ausschliesslich im Manager konfiguriert. Sie erzeugen
|
||||
keine Abhaengigkeit zwischen Enelix EMS und Enelix Utils.
|
||||
|
||||
| Anschluss | Aufgabe | Gegenstelle |
|
||||
| --- | --- | --- |
|
||||
| SDL/VGT | Zeitlich gültige Leistungsaufträge und Rückmeldungen | Optional das unabhängige Utils-Modul VGT-Schnittstelle |
|
||||
| Prognose / Forecast | Vorhersage für Produktion und bei Bedarf Verbrauch | Externer Anbieter; kein Repo-Modul |
|
||||
| SDL/VGT | Zeitlich gueltige Leistungsauftraege und Rueckmeldungen | Optional das unabhaengige Utils-Modul VGT-Schnittstelle |
|
||||
| Prognose / Forecast | Topologie, Telemetrie und Netzfahrplan | `license.enelix.ch/api/v1/installations/{id}/prognosis/*` |
|
||||
| Lizenzierung | Pruefung freigeschalteter Manager-Funktionen | `POST https://license.enelix.ch/api/v1/licenses/activate` |
|
||||
| Störüberwachung | Weitergabe von Zuständen und Störungen | Externer Empfänger; kein Repo-Modul |
|
||||
| Stoerueberwachung | Vollsnapshot aktiver Manager- und Geraetestoerungen | `PUT https://license.enelix.ch/api/v1/installations/{id}/faults` |
|
||||
|
||||
## Gemeinsame Grundsätze
|
||||
## Stoerungsdatenfluss
|
||||
|
||||
- Die Auswahl der Gegenstelle erfolgt ausschliesslich im Manager.
|
||||
- Anbieterformate werden an diesem Anschluss übersetzt und gelangen nicht in
|
||||
1. Jedes Geraetemodul meldet seinen Zustand ueber den bestehenden
|
||||
Verbraucher-Nachrichtenvertrag an den Manager.
|
||||
2. Der Manager sammelt aktive Eintraege mit `Art = Stoerung`, fuegt eigene
|
||||
Regelungsstoerungen hinzu und normalisiert sie.
|
||||
3. Der Manager speichert lokal einen deterministisch sortierten Vollsnapshot.
|
||||
4. Bei Zustandsaenderung oder spaetestens im konfigurierten Heartbeat-Intervall
|
||||
sendet er den Snapshot mit dem vorhandenen Geraete-Bearer-Token.
|
||||
5. Ein leerer Snapshot loest zuvor aktive Stoerungen im Portal auf.
|
||||
|
||||
Jede Meldung besteht aus `sourceType`, `sourceId`, `sourceName`, `code`,
|
||||
`severity` und `message`. Die stabile Kombination aus `sourceId` und
|
||||
`code` identifiziert ein Ereignis ueber mehrere Snapshots hinweg.
|
||||
|
||||
Fehler der externen Uebertragung blockieren die lokale EMS-Regelung nicht. Der
|
||||
Manager wiederholt mit exponentiellem Abstand zwischen 30 und 900 Sekunden.
|
||||
Nach HTTP 401 oder 403 verwirft er den Geraetezugang und fordert ihn bei der
|
||||
naechsten Lizenzaktivierung neu an.
|
||||
|
||||
## Lizenzvorbereitung
|
||||
|
||||
Die Stoerueberwachung ist technisch als separat schaltbare Manager-Funktion
|
||||
gekapselt. Das Portal kann spaeter eine Berechtigung mit dem Katalogschluessel
|
||||
`fault_monitoring`, einer Laufzeit von einem Jahr und einem erneuerten
|
||||
`validUntil` ausliefern. In der ersten Ausbaustufe wird diese zusaetzliche
|
||||
Jahresberechtigung noch nicht erzwungen; Voraussetzung bleibt eine gueltige
|
||||
Managerlizenz samt Geraetezugang.
|
||||
|
||||
## Gemeinsame Grundsaetze
|
||||
|
||||
- Anbieterformate werden am Manageranschluss uebersetzt und gelangen nicht in
|
||||
die Verbraucher-Schnittstelle.
|
||||
- Jeder Anschluss meldet im Manager `NichtVerwendet`, `Verbunden` oder `Fehler`.
|
||||
- Fehlende optionale Anschlüsse dürfen die lokale EMS-Grundfunktion nicht
|
||||
- Jeder Anschluss meldet `NichtVerwendet`, `Wartet`, `Verbunden` oder
|
||||
`Fehler`.
|
||||
- Fehlende optionale Anschluesse duerfen die lokale EMS-Grundfunktion nicht
|
||||
blockieren.
|
||||
- Eine fehlende oder abgelaufene Lizenz blockiert die Managerregelung. Eine
|
||||
bestaetigte Entwicklungs-Lease bleibt bei Verbindungsunterbruch bis
|
||||
`offlineUntil` gueltig.
|
||||
|
||||
- Zugangstoken werden weder als Property noch in Diagnosevariablen oder Logs
|
||||
ausgegeben.
|
||||
## Noch festzulegen
|
||||
|
||||
- Anbieter und Datenformat für Prognose und Störüberwachung
|
||||
- Aufbau, Gültigkeitszeitraum und Rückmeldung eines SDL-Auftrags
|
||||
- Authentisierung der noch optionalen Anschlüsse
|
||||
- Ablösung der Entwicklungs-Lease durch den signierten produktiven Lizenzvertrag
|
||||
- Aufbau, Gueltigkeitszeitraum und Rueckmeldung eines SDL-Auftrags
|
||||
- Produkt- und Zahlungsmodell der jaehrlichen Stoerueberwachungslizenz
|
||||
- Verhalten nach Ablauf einer spaeter aktivierten Stoerueberwachungslizenz
|
||||
|
||||
+24
-27
@@ -47,25 +47,13 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
||||
| ID | Status | Bereich | Kurzthema |
|
||||
| --- | --- | --- | --- |
|
||||
| OP-001 | Entschieden | Lizenzierung | Betriebsort des Lizenzservers |
|
||||
| OP-002 | Offen | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
|
||||
| OP-002 | Erledigt | Grundeinrichtung | Generator und kostenpflichtige Ersteinrichtung |
|
||||
| OP-003 | Offen | Lizenzportal | VGT-Integration in license.enelix.ch |
|
||||
| OP-004 | Offen | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
|
||||
| OP-004 | Entschieden | Lizenzierung | Lizenzmodell des Verbrauchskostenreports |
|
||||
| OP-005 | Offen | Teststrategie | Standort fuer Feldtests |
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
### OP-002: Generator und kostenpflichtige Ersteinrichtung
|
||||
|
||||
- **Entscheidungsfrage:** Soll der automatische Generator fuer die
|
||||
Grundeinrichtung zugleich die kostenpflichtige Ersteinrichtung abbilden?
|
||||
- **Kontext:** Der vorgesehene automatische Generator fuer die Grundeinrichtung
|
||||
muss gegenueber der kostenpflichtigen Ersteinrichtung abgegrenzt werden.
|
||||
- **Auswirkung:** Die Entscheidung legt fest, ob beide als derselbe Vorgang oder
|
||||
als getrennte Leistungen behandelt werden.
|
||||
- **Verantwortlich:** Offen
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
|
||||
### OP-003: VGT-Integration in license.enelix.ch
|
||||
|
||||
- **Entscheidungsfrage:** Soll die VGT-Anwendung als Bestandteil in die
|
||||
@@ -78,19 +66,6 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
|
||||
### OP-004: Lizenzmodell des Verbrauchskostenreports
|
||||
|
||||
- **Entscheidungsfrage:** Soll der Verbrauchskostenreport nach dem Lizenzmodell
|
||||
des Managers oder mit einer einzigen, von der Anzahl angeschlossener Zaehler
|
||||
unabhaengigen Modullizenz angeboten werden?
|
||||
- **Kontext:** Fuer den Verbrauchskostenreport muss zwischen dem Lizenzmodell des
|
||||
Managers und einer zaehlerunabhaengigen Modullizenz entschieden werden.
|
||||
- **Auswirkung:** Die Entscheidung legt fest, ob die Anzahl angeschlossener
|
||||
Zaehler den Lizenzumfang des Verbrauchskostenreports beeinflusst.
|
||||
- **Verantwortlich:** Offen
|
||||
- **Ziel-Sprint:** Offen
|
||||
- **Ergebnis/Verweis:** Offen
|
||||
|
||||
### OP-005: Standort fuer Feldtests
|
||||
|
||||
- **Entscheidungsfrage:** An welchem Standort koennen die Module aus Enelix EMS
|
||||
@@ -113,6 +88,28 @@ Prioritaeten, Fehlerverhalten und ein pruefbares Ergebnis.
|
||||
- **Ergebnis/Verweis:** [Obere Anschluesse](Obere-Anschluesse.md) und
|
||||
[Manager](module/Manager/README.md)
|
||||
|
||||
### OP-002: Generator und kostenpflichtige Ersteinrichtung
|
||||
|
||||
- **Entscheidung:** Der automatische Systemgenerator und die kostenpflichtige
|
||||
Ersteinrichtung sind derselbe Vorgang.
|
||||
- **Abrechnung:** Eine Bestellung aus dem Systemkonfigurator enthaelt
|
||||
automatisch die noch nicht bezahlten Einrichtungskosten fuer den Manager und
|
||||
die konfigurierten Verbrauchermodule. Bereits bezahlte Einrichtungsmengen
|
||||
werden je Anlage angerechnet. Direkte Lizenzbestellungen enthalten keine
|
||||
Einrichtungskosten.
|
||||
- **Status:** Erledigt
|
||||
- **Ergebnis/Verweis:** Umsetzung im Lizenzportal unter
|
||||
`https://license.enelix.ch`.
|
||||
|
||||
### OP-004: Lizenzmodell des Verbrauchskostenreports
|
||||
|
||||
- **Entscheidung:** Der Verbrauchskostenreport verwendet eine Grundlizenz sowie
|
||||
getrennte Kontingente fuer Stromzaehler und Nebenzaehler. Die Anzahl der
|
||||
konfigurierten Zaehler wird bei der Lizenzpruefung beruecksichtigt.
|
||||
- **Status:** Entschieden und implementiert
|
||||
- **Ergebnis/Verweis:** Enelix Utils, Commit `1f945be` und
|
||||
`Verbrauchskostenreport/module.php`.
|
||||
|
||||
## Vorlage fuer neue Punkte
|
||||
|
||||
```markdown
|
||||
|
||||
@@ -29,8 +29,8 @@ Sollleistung_W verwendet folgende Semantik:
|
||||
- null: nur Betriebsart synchronisieren und neues Angebot anfordern
|
||||
|
||||
Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten
|
||||
Leistungsangebot enthalten sein. Waehren der Aenderungssperre wird nur die
|
||||
Wiederholung des aktuellen Sollwerts akzeptiert.
|
||||
Einzelwert oder Leistungsbereich enthalten sein. Waehrend der Aenderungssperre
|
||||
wird nur die Wiederholung des aktuellen Sollwerts akzeptiert.
|
||||
|
||||
## Batterie an Manager
|
||||
|
||||
@@ -56,10 +56,10 @@ Beispiel:
|
||||
"EmpfaengerID": 10001,
|
||||
"Zeitpunkt": 1788825602
|
||||
},
|
||||
"Betriebsart": "Peak",
|
||||
"Betriebsart": "PV",
|
||||
"PrioritaetPV": 0,
|
||||
"PrioritaetPeak": 0,
|
||||
"Leistungswerte_W": [-1500],
|
||||
"Leistungswerte_W": [{"Von_W": -5000, "Bis_W": 5000}],
|
||||
"AenderungMoeglich": true,
|
||||
"Verfuegbar": true,
|
||||
"Istleistung_W": -250.0,
|
||||
@@ -68,7 +68,7 @@ Beispiel:
|
||||
{
|
||||
"Kennung": "Sollleistung_W",
|
||||
"Art": "Sollwert",
|
||||
"Wert": -1500,
|
||||
"Wert": -537,
|
||||
"Einheit": "W"
|
||||
},
|
||||
{
|
||||
@@ -83,7 +83,9 @@ Beispiel:
|
||||
|
||||
Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und
|
||||
Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist
|
||||
Leistungsquelle 2.
|
||||
Leistungsquelle 2. Frei regelbare Lade- und Entladeangebote werden als
|
||||
inklusive Bereiche in ganzen Watt gemeldet. Feste Schutz- und Peakvorgaben
|
||||
bleiben einzelne Leistungswerte.
|
||||
|
||||
## Ereignisse
|
||||
|
||||
|
||||
+34
-2
@@ -41,6 +41,29 @@ berechnet sein Leistungsangebot neu und meldet es zurueck. Eine vorhandene
|
||||
Sollleistung wird dabei nur verworfen, wenn sie im neuen Angebot nicht mehr
|
||||
zulaessig ist.
|
||||
|
||||
### Verworfene Sollwerte und Anzeige
|
||||
|
||||
Beim Verwerfen einer gueltigen Vorgabe setzen alle Verbrauchermodule sowohl
|
||||
`SollwertGueltig=false` als auch den gespeicherten und gegebenenfalls sichtbaren
|
||||
Wert `Sollleistung=0`. An den Manager geht weiterhin `Sollleistung_W=null`;
|
||||
die Anzeige 0 W ist keine neue gueltige Manager-Vorgabe und kein Nachweis einer
|
||||
physisch ausgeschalteten Last.
|
||||
|
||||
Lokale Schutzprogramme, Mindestlaufzeiten und bestehende Lade-Uebergaenge
|
||||
bleiben unveraendert. Berechnet ein Modul danach eine lokale Sollleistung,
|
||||
darf es diese weiterhin anzeigen. Bei Ladestationen bleibt waehrend eines
|
||||
Regeluebergangs insbesondere der letzte Geraetebefehl erhalten, auch wenn die
|
||||
alte Manager-Vorgabe bereits verworfen und ihre Anzeige auf 0 gesetzt wurde.
|
||||
Wiederholte Verwerfungen einer bereits ungueltigen Manager-Vorgabe lassen eine
|
||||
inzwischen neu berechnete lokale Schutzleistung unveraendert.
|
||||
|
||||
Nach einem Modulupdate bereinigt `ApplyChanges` bereits gespeicherte ungueltige
|
||||
Altwerte. Gueltige Vorgaben einschliesslich 0 W und negativer Batterieleistung
|
||||
bleiben erhalten. Keine neuen Properties, Variablen-IDs oder Vertragsversion;
|
||||
es ist keine manuelle Konfigurationsmigration erforderlich. Das Update ist
|
||||
kontrolliert anzuwenden, da die regulaere Instanzinitialisierung wie bisher
|
||||
Geraeteaktionen ausloesen kann.
|
||||
|
||||
## Verbraucher an Manager
|
||||
|
||||
```json
|
||||
@@ -78,8 +101,11 @@ zulaessig ist.
|
||||
`Sollleistung_W=null`.
|
||||
4. Jeder Verbraucher berechnet und meldet seine PowerSteps fuer diese
|
||||
Betriebsart.
|
||||
5. Erst wenn alle aktiven Verbraucher synchronisiert sind, verteilt der Manager
|
||||
Sollleistungen.
|
||||
5. Der Manager verteilt Sollleistungen an alle bereits synchronisierten
|
||||
Verbraucher.
|
||||
6. Fehlende, veraltete oder noch nicht umgeschaltete Verbraucher werden nicht
|
||||
angesteuert und als Stoerung ausgewiesen; sie blockieren die aktuellen
|
||||
Verbraucher nicht.
|
||||
|
||||
Damit kann jeder Verbrauchertyp unterschiedliche Angebote fuer PV und Peak
|
||||
melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
|
||||
@@ -87,6 +113,12 @@ melden, ohne dass der Manager seine interne Geraetelogik kennen muss.
|
||||
## Feste Regeln
|
||||
|
||||
- Prioritaeten beginnen bei 0; eine kleinere Zahl bedeutet hoehere Prioritaet.
|
||||
- Jeder verfuegbare, synchronisierte Verbraucher mit einem nicht leeren
|
||||
`Leistungswerte_W`-Angebot erhaelt einen Sollwert aus genau diesem Angebot.
|
||||
- `AenderungMoeglich=false` kennzeichnet ein fixes Angebot und ist kein
|
||||
Ausschlussgrund. Ein Angebot `[11000]` fuehrt deshalb zwingend zu `11000 W`.
|
||||
- Prioritaeten verteilen nur die ueber den jeweiligen Mindestwert hinaus
|
||||
verfuegbare Leistung; sie duerfen keinen angebotenen Mindestwert verdrängen.
|
||||
- `Leistungsquelle`: 0 nicht vorhanden, 1 berechnet, 2 gemessen.
|
||||
- Bei Leistungsquelle 0 ist `Istleistung_W` zwingend `null`.
|
||||
- Leistungsbereiche enthalten jeden ganzen Wattwert von `Von_W` bis `Bis_W`.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# ADR 0005: Anlagentopologie als Manager-Stammdaten
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
|
||||
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
|
||||
Installation und werden auch von der lokalen Regelung benoetigt. Eine
|
||||
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
|
||||
erzeugen.
|
||||
|
||||
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
|
||||
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
|
||||
Wechselrichterleistung behandelt werden.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
|
||||
Anlagentopologie. Er speichert drei getrennte Listen:
|
||||
|
||||
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
|
||||
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
|
||||
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
|
||||
Wechselrichterbezug.
|
||||
|
||||
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
|
||||
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
|
||||
|
||||
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
|
||||
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
|
||||
muessen gemeinsam gepflegt werden.
|
||||
|
||||
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
|
||||
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
|
||||
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
|
||||
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
|
||||
|
||||
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
|
||||
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
|
||||
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
|
||||
|
||||
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
|
||||
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
|
||||
standardmaessig nur angezeigt.
|
||||
|
||||
## Alternativen
|
||||
|
||||
### Vollstaendige Pflege im Prognoseportal
|
||||
|
||||
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
|
||||
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
|
||||
abweichen.
|
||||
|
||||
### Batterie und PV als ein Hybridobjekt speichern
|
||||
|
||||
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
|
||||
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
|
||||
Kapazitaets- und Leistungsgrenzen verloren.
|
||||
|
||||
### AC- und DC-Leistung an jeder PV-Flaeche speichern
|
||||
|
||||
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
|
||||
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
|
||||
gespeichert.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
|
||||
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
|
||||
Manager-Konfiguration kontrolliert.
|
||||
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
|
||||
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
|
||||
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
|
||||
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
|
||||
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
|
||||
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
|
||||
dauerhaftes API-Secret verwendet.
|
||||
@@ -11,7 +11,6 @@ Verhaltensreferenz.
|
||||
- positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen
|
||||
- dynamische maximale Lade- und Entladeleistung aus Variablen
|
||||
- Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben
|
||||
- 250-W-Grundraster und 50-W-Feinwerte um 0
|
||||
- Reserve-, Mindestladezustands- und Hystereselogik
|
||||
- getrennte Angebote fuer PV und Peak
|
||||
- Wechselrichter- oder Enelix-Steuerung
|
||||
@@ -25,6 +24,7 @@ Verhaltensreferenz.
|
||||
| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse |
|
||||
| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer |
|
||||
| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag |
|
||||
| 250-W-Grundraster mit 50-W-Feinwerten | inklusive Leistungsbereiche in ganzen Watt |
|
||||
| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction |
|
||||
| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl |
|
||||
| fest codierte 2-%-Hysterese | Property LadezustandHysterese |
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
| `Interval` | entfaellt | Die Regelung wird ereignisbasiert ausgeloest. |
|
||||
| `IdleCounterMax` | `LastwechselSperrzeit` | Zykluszaehler wird durch eine Sperrzeit in Sekunden ersetzt; Standard `5`. |
|
||||
|
||||
Die neuen Properties `TemperaturMaxAlter`, `Hysterese`, die Parameter der
|
||||
Legionellenfunktion und `DiagnosevariablenAnzeigen` ersetzen feste Werte und
|
||||
nicht pruefbare Annahmen des Altmoduls.
|
||||
Die neuen Properties `TemperaturMaxAlter`, `TemperaturUntergrenze`,
|
||||
`TemperaturObergrenze`, `Hysterese`, die Parameter der Legionellenfunktion und
|
||||
`DiagnosevariablenAnzeigen` ersetzen feste Werte und nicht pruefbare Annahmen
|
||||
des Altmoduls. Die Stoergrenzen liegen standardmaessig bei 0 und 100 Grad C und
|
||||
sind von den Regel-Sollwerten `Mindesttemperatur` und `Maximaltemperatur` getrennt.
|
||||
|
||||
## Variablen
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ EinstellungenInVisu und LoggingEin stammen aus VerbraucherBasisTrait.
|
||||
| ReserveLadezustand | Float | 20 | Peakshaving-Reserve in Prozent |
|
||||
| MindestLadezustand | Float | 10 | Untere Entladegrenze in Prozent |
|
||||
| LadezustandHysterese | Float | 2 | Hysterese oberhalb der Reserve |
|
||||
| NachladenMitMaximalleistung | Boolean | true | Verwendet fuer schutzbedingtes Nachladen die dynamische maximale Ladeleistung |
|
||||
| MaximaleNachladeleistung | Integer | 0 | Benutzerdefinierte Nachladegrenze in W; bei deaktivierter Maximalleistung muss der Wert groesser als 0 sein |
|
||||
| MesswertMaxAlter | Integer | 60 | Maximales Messwertalter in Sekunden |
|
||||
| Aenderungssperre | Integer | 4 | Sperrzeit nach Sollwertaenderung |
|
||||
| DiagnosevariablenAnzeigen | Boolean | false | Technische Variablen einblenden |
|
||||
@@ -73,13 +75,20 @@ zuletzt Management geschrieben.
|
||||
|
||||
## Leistungsangebot
|
||||
|
||||
Das Raster besteht wie in Enelix 1 aus 250-W-Grundschritten. Um 0 werden
|
||||
zusaetzlich -200, -150, -100, -50, 0, 50, 100, 150 und 200 W angeboten,
|
||||
soweit diese Werte innerhalb der dynamischen Leistungsgrenzen liegen.
|
||||
Die Batterie meldet innerhalb der dynamischen Lade- und Entladegrenzen
|
||||
inklusive Leistungsbereiche in ganzen Watt. Der Manager kann dadurch jeden
|
||||
ganzzahligen Sollwert innerhalb des aktuell erlaubten Bereichs vorgeben. Die
|
||||
Grenzen werden abgerundet, damit keine dynamische Maximalleistung ueberschritten
|
||||
wird.
|
||||
|
||||
PV und Peak verwenden die bisherige SoC-Logik. Die Hysterese wird als
|
||||
persistenter Modulzustand gefuehrt. Bei Wechselrichtersteuerung, lokaler
|
||||
Deaktivierung oder ungueltigen Messwerten lautet das Angebot [0].
|
||||
PV und Peak verwenden weiterhin die bisherige SoC-Logik. Feste Schutz- oder
|
||||
Peakvorgaben bleiben Einzelwerte; frei regelbare Angebote werden als Bereiche
|
||||
gemeldet. Bei schutzbedingtem Nachladen innerhalb oder unterhalb der Reserve
|
||||
begrenzt MaximaleNachladeleistung die positive Angebotsgrenze, wenn
|
||||
NachladenMitMaximalleistung deaktiviert ist. Der normale Ladebereich oberhalb
|
||||
der Reserve bleibt unveraendert. Die Hysterese wird als persistenter Modulzustand
|
||||
gefuehrt. Bei Wechselrichtersteuerung, lokaler Deaktivierung oder ungueltigen
|
||||
Messwerten lautet das Angebot [0].
|
||||
|
||||
## Variablen
|
||||
|
||||
@@ -115,7 +124,7 @@ Transport und gemeinsame Felder folgen
|
||||
|
||||
## Tests
|
||||
|
||||
- BatterieReglerTest: Raster, Hysterese, PV/Peak und Herstellerabbildung
|
||||
- BatterieReglerTest: Leistungsbereiche, Hysterese, PV/Peak und Herstellerabbildung
|
||||
- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID
|
||||
- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter
|
||||
IP-Symcon 8.0
|
||||
|
||||
@@ -50,20 +50,23 @@ als ungueltig behandelt und ergeben bis zur gueltigen 1-/3-Phasenmeldung nur
|
||||
| `PrioritaetPeak` | Integer | `0` | Prioritaet im Peakbetrieb. |
|
||||
| `Meldeintervall` | Integer | `10 s` | Periodische Vollmeldung an den Manager. |
|
||||
| `VorgabeTimeout` | Integer | `120 s` | Ablauf einer nicht erneuerten Manager-Vorgabe. |
|
||||
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe und Solarladen. |
|
||||
| `EinstellungenInVisu` | Boolean | `false` | Zeigt Ladefreigabe, Solarladen, Istleistung und Sollleistung. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Zeigt technische Diagnosewerte. |
|
||||
| `LoggingEin` | Boolean | `false` | Aktiviert Debugmeldungen ohne Geheimnisse. |
|
||||
|
||||
## Variablen
|
||||
|
||||
Immer sichtbar sind `Aktiv`, `FahrzeugVerbunden`, `FahrzeugGeladen`,
|
||||
`Fahrzeugstatus`, `Ladestrom` und `Phasenzahl`. Der normalisierte
|
||||
Fahrzeugstatus ist `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt,
|
||||
`4` geladen oder `5` Fehler.
|
||||
`Ladestrom` und `Phasenzahl`. Mit `EinstellungenInVisu=true` erscheinen
|
||||
zusaetzlich `Ladefreigabe`, `Solarladen`, `Istleistung` und `Sollleistung`.
|
||||
|
||||
Optional sichtbar sind die gemeinsamen EMS-Diagnosewerte sowie
|
||||
`GatewayVerbunden`, `ApiMaximalstrom`, `LetzterGeraetebefehl` und
|
||||
`LeistungsangebotDiagnose`.
|
||||
Mit `DiagnosevariablenAnzeigen=true` werden der numerische `Fahrzeugstatus`
|
||||
und die gemeinsamen EMS-Diagnosewerte sowie
|
||||
`GatewayVerbunden`, `ApiMaximalstrom`, `EaseeAktiviert`,
|
||||
`AutorisierungErforderlich`, `RemoteStartErforderlich`, `SmartCharging`,
|
||||
`GrundKeinLadestrom`, `LetzterGeraetebefehl`, `LetzterStartbefehl` und
|
||||
`LeistungsangebotDiagnose` angezeigt. `GrundKeinLadestrom=55` bedeutet bei Easee, dass
|
||||
die Autorisierung fehlt.
|
||||
|
||||
## Regelung
|
||||
|
||||
@@ -80,10 +83,18 @@ Ladestation Stand-Alone:
|
||||
Die niedrigste Grenze aus Property, Observation `47` und Observation `104`
|
||||
bestimmt den angebotenen Maximalstrom. Eine neue Observation berechnet das
|
||||
Angebot sofort neu und meldet die Aenderung kurz gebuendelt an den Manager.
|
||||
Beim Aktivieren oder Umschalten von Solarladen bleibt der bisherige
|
||||
Geraetestrom bis zur neuen Manager-Vorgabe unveraendert. Ohne Managerantwort
|
||||
greift nach zwei Sekunden das lokale Ersatzverhalten. Sicherheitsabschaltungen
|
||||
werden nicht verzoegert.
|
||||
|
||||
Bei einer Gateway-Unterbrechung werden Fahrzeug- und Phasenstatus sofort
|
||||
verworfen; erst ein neuer aktueller Gateway-Zustand gibt die Regelung wieder
|
||||
frei. Die eigentliche Vorgabe wird als dynamischer Ladestrom mit `minutes=0`
|
||||
an Easee gesendet.
|
||||
an Easee gesendet. Meldet Easee bei positiver Stromvorgabe weiterhin
|
||||
`Awaiting Start`, sendet das Modul einmal pro Fahrzeugverbindung zusaetzlich
|
||||
`start_charging`. Dieser Befehl autorisiert den Ladevorgang, uebersteuert aber
|
||||
keinen vorhandenen Easee-Ladeplan.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
|
||||
@@ -1,63 +1,331 @@
|
||||
# Ladestation Stand-Alone
|
||||
|
||||
> Status: Implementiert. Eigenstaendiges Verbrauchermodul mit direkter
|
||||
> Geraeteanbindung; keine Beziehung zur Ladestation Gateway.
|
||||
> Status: implementiert fuer IP-Symcon 8 und den Enelix-2-Vertrag `4.0`.
|
||||
|
||||
Das Modul bindet eine einzelne Ladestation direkt an Enelix EMS an. Es liest
|
||||
den Fahrzeug- und Ladezustand ueber die Geraete-API, ermittelt die Phasenzahl
|
||||
wie Enelix 1 und setzt den vom Manager gewaehlten Ladestrom. Es benoetigt keine
|
||||
Gateway-Instanz und hat keine technische Beziehung zum separaten Modul
|
||||
**Ladestation Gateway**.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- direkte Statusabfrage und Steuerung der unterstuetzten Ladestationen,
|
||||
- Fahrzeug-, Lade- und Phasenerkennung nach der Enelix-1-Logik,
|
||||
- Leistungsangebote fuer PV- und Peakbetrieb,
|
||||
- lokale Freigabe und Umschaltung zwischen Solar- und Normalbetrieb,
|
||||
- Kommunikation mit dem Enelix Manager ueber den Nachrichtenvertrag `4.0`,
|
||||
- optional sichtbare Diagnosevariablen und schaltbares Debug-Logging,
|
||||
- automatisierte Tests mit Fake-HTTP-Transport ohne reale Ladestation.
|
||||
|
||||
## Unterstuetzte Geraete
|
||||
|
||||
| Geraetetyp | Status | Steuerung |
|
||||
| Geraetetyp | Statusabfrage | Steuerung | Erforderliche Konfiguration |
|
||||
| --- | --- | --- | --- |
|
||||
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` | IP-Adresse oder Hostname |
|
||||
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` | IP-Adresse oder Hostname |
|
||||
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API | Geraete-ID, Seriennummer, Benutzername und Passwort |
|
||||
|
||||
Bei go-e wird die lokale HTTP-API verwendet. Die Geraeteadresse wird ohne
|
||||
`http://`, Pfad oder Parameter eingetragen. Die Pico-Anbindung verwendet HTTPS
|
||||
und HTTP Basic Auth gegen `api.smart-me.com`. Zugangsdaten werden weder als
|
||||
Variable noch im Debug-Protokoll oder im Diagnosefeld
|
||||
`LetzterGeraetebefehl` ausgegeben.
|
||||
|
||||
## Fahrzeug- und Phasenerkennung
|
||||
|
||||
Die Auswertung folgt bewusst dem Verhalten der bisherigen Enelix-1-Ladestation:
|
||||
|
||||
1. Bei go-e gilt das Fahrzeug als verbunden, wenn `car != 1` ist. Bei Pico
|
||||
wird entsprechend `State != 1` ausgewertet.
|
||||
2. Die gemessene Leistung wird bei der alten go-e-API mit Faktor 10, bei
|
||||
Gemini direkt in Watt und bei Pico von kW in Watt umgerechnet.
|
||||
3. go-e meldet die drei Phasenstroeme einzeln. Sobald mindestens zwei Phasen
|
||||
Strom fuehren, wird dreiphasiges Laden direkt erkannt.
|
||||
4. Sind beim Anstecken noch keine belastbaren Phasenwerte vorhanden, gibt das
|
||||
Modul zunaechst nur den Mindeststrom von 6 A als Erkennungsstrom vor. Bis zur
|
||||
abgeschlossenen Erkennung bleibt `Phasenzahl=0` und das EMS-Angebot `[0]`.
|
||||
5. Nach `Phasenerkennungszeit` wird die gemessene Leistung relativ zum
|
||||
angeforderten Erkennungsstrom ausgewertet. Dadurch lassen sich eine und drei
|
||||
Phasen bereits bei 6 A unterscheiden. Die erkannte Phasenzahl bleibt bis
|
||||
zum Ausstecken erhalten und kippt waehrend einer Ladepause nicht zurueck.
|
||||
6. Der Ladestrom wird aus der Leistung mit `230 W/A` einphasig beziehungsweise
|
||||
`684 W/A` dreiphasig berechnet.
|
||||
7. Ein Ladeende wird nur nach zuvor tatsaechlich gemessener Ladeleistung
|
||||
erkannt. Eine vom EMS angeordnete Solarpause gilt deshalb nicht als
|
||||
`FahrzeugGeladen`.
|
||||
|
||||
Der normalisierte `Fahrzeugstatus` verwendet folgende Werte:
|
||||
|
||||
| Wert | Bedeutung |
|
||||
| ---: | --- |
|
||||
| `0` | unbekannt oder noch nicht ermittelt |
|
||||
| `1` | kein Fahrzeug verbunden |
|
||||
| `2` | Fahrzeug verbunden und bereit |
|
||||
| `3` | Fahrzeug laedt |
|
||||
| `4` | Fahrzeug geladen |
|
||||
|
||||
## Leistungsregelung
|
||||
|
||||
Ein Leistungsangebot groesser als `0 W` wird nur erzeugt, wenn das Modul
|
||||
aktiviert, die Ladefreigabe gesetzt, ein Fahrzeug verbunden und das Fahrzeug
|
||||
noch nicht als geladen erkannt ist. Andernfalls meldet das Modul `[0]` und
|
||||
stoppt die Ladestation.
|
||||
|
||||
| Betriebsart | Solarladen | Leistungsangebot |
|
||||
| --- | --- | --- |
|
||||
| go-e Charger, alte API | `GET /mqtt?payload=` | `alw` und `amp` |
|
||||
| go-e Charger Gemini / Gemini flex | `GET /api/status` | `frc` und `amp` |
|
||||
| smart-me Pico | Pico-Charging-API | Load-Management-Current-API mit Basic Auth |
|
||||
| PV | ein | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||
| PV | aus | ausschliesslich die maximale Ladeleistung |
|
||||
| Peak | ein | ausschliesslich `[0]`; die Ladestation wird gestoppt |
|
||||
| Peak | aus | `0 W` und alle Ladestufen von 6 A bis zum konfigurierten Maximum |
|
||||
|
||||
Die Routen, Leistungsfaktoren und Erkennung wurden gezielt aus Enelix 1
|
||||
uebernommen. Zugangsdaten werden weder als Variable noch im Diagnoseprotokoll
|
||||
ausgegeben.
|
||||
Die Leistungsstufen entsprechen Enelix 1: `230 W` pro Ampere einphasig und
|
||||
`684 W` pro Ampere dreiphasig. Ohne gueltige Manager-Vorgabe faehrt das Modul
|
||||
bei aktivem Solarladen mit `0 A`; bei ausgeschaltetem Solarladen verwendet es
|
||||
die maximale angebotene Leistung.
|
||||
|
||||
## Verhalten
|
||||
Beim Aktivieren oder Umschalten von Solarladen bleibt ein bereits gesetzter
|
||||
Geraetestrom zunaechst unveraendert. Das neue Angebot wird zuerst an den
|
||||
zugeordneten Manager gemeldet; dessen neue Vorgabe wird anschliessend direkt
|
||||
uebernommen. Antwortet kein Manager, greift nach zwei Sekunden das lokale
|
||||
Ersatzverhalten. Sicherheitsabschaltungen wirken weiterhin sofort.
|
||||
|
||||
- `FahrzeugVerbunden` entspricht bei go-e `car != 1` und bei Pico `State != 1`.
|
||||
- Die Phasenerkennung verwendet wie Enelix 1 die gemessene Ladeleistung:
|
||||
ueber 7500 W sind dreiphasig, sonst einphasig.
|
||||
- Ein bereits verbundenes Fahrzeug gilt bei einem ermittelten Maximalstrom
|
||||
unter 6 A als geladen.
|
||||
- PV mit Solarladen bietet `0 W` und alle Ladestufen von 6 A bis zum
|
||||
konfigurierten Maximum an.
|
||||
- PV ohne Solarladen bietet ausschliesslich die Maximalleistung an.
|
||||
- Peak mit Solarladen bietet nur `[0]` an und stoppt die Ladestation.
|
||||
- Peak ohne Solarladen bietet `[0, ...Ladestufen]` an, damit der Manager die
|
||||
Ladeleistung wie in Enelix 1 stufenweise reduzieren kann.
|
||||
- Ohne gueltige Manager-Vorgabe wird bei Solarladen mit 0 A und ohne
|
||||
Solarladen mit maximaler Leistung gefahren.
|
||||
Nach einer Leistungsaenderung wird die aktuelle Stufe fuer
|
||||
`ZeitZwischenZustandswechseln` gehalten. Waehrend der
|
||||
`Mindesteinschaltdauer` bleiben positive Leistungsstufen regelbar, `0 W`
|
||||
wird jedoch nicht angeboten. Nach dem Abschalten meldet das Modul waehrend der
|
||||
`Mindestausschaltdauer` ausschliesslich `[0]`. Sicherheitsabschaltungen
|
||||
durch Deaktivierung, fehlende Ladefreigabe, Ausstecken oder ein erkanntes
|
||||
Ladeende greifen sofort.
|
||||
|
||||
## Bedienung
|
||||
|
||||
| Ident | Wirkung |
|
||||
| --- | --- |
|
||||
| `Aktiv` | Gemeinsame lokale EMS-Freigabe. `false` setzt das Angebot auf `[0]` und stoppt die Ladestation. |
|
||||
| `Ladefreigabe` | Lokale Freigabe fuer das Laden. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||
| `Solarladen` | Schaltet das oben beschriebene PV-/Peak-Verhalten um. Diese Variable ist nur mit `EinstellungenInVisu=true` sichtbar. |
|
||||
|
||||
Die Properties `Ladefreigabe` und `Solarladen` definieren die Startwerte. Eine
|
||||
Aenderung der jeweiligen Property wird beim Anwenden der Konfiguration in den
|
||||
lokalen Zustand uebernommen. Eine Bedienung der Variablen verwirft eine noch
|
||||
gueltige Manager-Vorgabe und berechnet das Leistungsangebot sofort neu.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Gemeinsame EMS-Einstellungen
|
||||
|
||||
| Property | Typ | Standard | Beschreibung |
|
||||
| --- | --- | ---: | --- |
|
||||
| `PrioritaetPV` | Integer | `0` | Prioritaet des Verbrauchers im PV-Betrieb. |
|
||||
| `PrioritaetPeak` | Integer | `0` | Prioritaet des Verbrauchers im Peakbetrieb. |
|
||||
| `Meldeintervall` | Integer | `10 s` | Intervall fuer die periodische Vollmeldung an zugeordnete Manager. |
|
||||
| `VorgabeTimeout` | Integer | `120 s` | Gueltigkeitsdauer einer Manager-Vorgabe ohne Erneuerung. |
|
||||
| `EinstellungenInVisu` | Boolean | `false` | Blendet `Ladefreigabe`, `Solarladen`, `Istleistung` und `Sollleistung` ein. |
|
||||
| `LoggingEin` | Boolean | `false` | Aktiviert das laufende Debug-Protokoll des Moduls. |
|
||||
|
||||
### Ladestation und Diagnose
|
||||
|
||||
| Property | Typ | Standard | Beschreibung |
|
||||
| --- | --- | ---: | --- |
|
||||
| `Geraetetyp` | Integer | `0` | `1` go-e alt, `2` go-e Gemini, `3` smart-me Pico; `0` ist nicht konfiguriert. |
|
||||
| `Geraeteadresse` | String | leer | IP-Adresse oder Hostname fuer beide go-e-Varianten. |
|
||||
| `GeraeteID` | String | leer | Geraete-ID fuer die Pico-Statusabfrage. |
|
||||
| `Seriennummer` | String | leer | Seriennummer fuer die Pico-Stromvorgabe. |
|
||||
| `Benutzername` | String | leer | Benutzername fuer die Pico-API. |
|
||||
| `Passwort` | String | leer | Passwort fuer die Pico-API; im Formular als Passwortfeld dargestellt. |
|
||||
| `MaximalerLadestrom` | Integer | `16 A` | Obergrenze der angebotenen Ladestufen; zulaessig sind 6 bis 32 A. |
|
||||
| `Phasenerkennungszeit` | Integer | `60 s` | Dauer der 6-A-Probe vor der leistungsbasierten Phasenerkennung. |
|
||||
| `FahrzeugstromErkennungszeit` | Integer | `90 s` | Zeit, die eine stabile Stromunterschreitung anliegen muss, bevor die Fahrzeuggrenze mit 2,5 A Reserve uebernommen wird. |
|
||||
| `ZeitZwischenZustandswechseln` | Integer | `1 min` | Mindestabstand zwischen zwei Leistungsaenderungen. |
|
||||
| `Mindesteinschaltdauer` | Integer | `0 min` | Mindestdauer nach dem Einschalten, in der kein Abschalten auf 0 W angeboten wird. |
|
||||
| `Mindestausschaltdauer` | Integer | `0 min` | Mindestpause nach dem Abschalten, in der nur 0 W angeboten werden. |
|
||||
| `Abfrageintervall` | Integer | `5 s` | Intervall der Geraetestatusabfrage. |
|
||||
| `Ladefreigabe` | Boolean | `true` | Startwert der lokalen Ladefreigabe. |
|
||||
| `Solarladen` | Boolean | `true` | Startwert der lokalen Solarlogik. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false` | Blendet gemeinsame und ladestationsspezifische Diagnosevariablen ein. |
|
||||
|
||||
Die internen Properties `Testmodus` und `Testantwort` gehoeren ausschliesslich
|
||||
zum automatisierten Symcon-Funktionstest und erscheinen nicht im
|
||||
Konfigurationsformular.
|
||||
|
||||
## Variablen
|
||||
|
||||
Immer sichtbar:
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Geraet meldet ein verbundenes Fahrzeug. |
|
||||
| `FahrzeugGeladen` | Boolean / Anzeige | Enelix-1-Erkennung unter 6 A. |
|
||||
| `Fahrzeugstatus` | Integer / Anzeige | `0` unbekannt, `1` getrennt, `2` bereit, `3` laedt, `4` geladen. |
|
||||
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Strom in A. |
|
||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig, `3` dreiphasig. |
|
||||
| `Ladefreigabe` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; lokale Ladeerlaubnis. |
|
||||
| `Solarladen` | Boolean / bedienbar | Nur bei `EinstellungenInVisu`; variable Ladestufen ein/aus. |
|
||||
| `Aktiv` | Boolean / bedienbar | Lokale EMS-Freigabe; der Initialwert ist `false`. |
|
||||
| `FahrzeugVerbunden` | Boolean / Anzeige | Zeigt, ob die API ein verbundenes Fahrzeug meldet. |
|
||||
| `FahrzeugGeladen` | Boolean / Anzeige | Ergebnis der kompatiblen Enelix-1-Voll-Erkennung. |
|
||||
| `Ladestrom` | Float / Anzeige | Aus der gemessenen Leistung ermittelter Ladestrom in A. |
|
||||
| `Phasenzahl` | Integer / Anzeige | `0` unbekannt, `1` einphasig oder `3` dreiphasig. |
|
||||
|
||||
## Properties
|
||||
Nur mit `EinstellungenInVisu=true` sichtbar; Ladefreigabe und Solarladen sind bedienbar:
|
||||
|
||||
Neben den gemeinsamen Verbraucher-Properties werden Geraetetyp, Adresse,
|
||||
Pico-ID, Seriennummer, Zugangsdaten, maximaler Ladestrom, Abfrageintervall
|
||||
sowie die Startwerte fuer Ladefreigabe und Solarladen konfiguriert.
|
||||
- `Ladefreigabe`
|
||||
- `Solarladen`
|
||||
- `Istleistung`
|
||||
- `Sollleistung`
|
||||
|
||||
Die gemeinsamen Verbraucher- und Diagnosevariablen entsprechen der
|
||||
EMS-Schnittstelle Version 4.0. `LoggingEin` steuert ausschliesslich das
|
||||
laufende Debug-Protokoll.
|
||||
Nur mit `DiagnosevariablenAnzeigen=true` sichtbar:
|
||||
|
||||
| Ident | Beschreibung |
|
||||
| --- | --- |
|
||||
| `Leistungsquelle` | Immer `2` fuer eine gemessene Leistung. |
|
||||
| `Fahrzeugstatus` | Normalisierter Status von `0` bis `4`. |
|
||||
| `SollwertGueltig` | Zeigt, ob die Vorgabe noch gueltig und im aktuellen Angebot enthalten ist. |
|
||||
| `Verfuegbar` | Zeigt, ob die Ladestation grundsaetzlich Leistung aufnehmen kann. |
|
||||
| `AenderungMoeglich` | Zeigt, ob das aktuelle Angebot mehr als eine Leistungsstufe enthaelt. |
|
||||
| `Stoerung` | Sammelstatus fuer Konfigurations- und Kommunikationsfehler. |
|
||||
| `Stoertext` | Letzte verstaendliche Fehlerbeschreibung. |
|
||||
| `LetzterGeraetebefehl` | Letzter Steuerbefehl mit HTTP-Methode und URL ohne Zugangsdaten; Statusabfragen ueberschreiben ihn nicht. |
|
||||
| `LeistungsangebotDiagnose` | Aktuelles Leistungsangebot als JSON-Liste in W. |
|
||||
|
||||
Die Regellogik speichert ihren Zustand unabhaengig von der Sichtbarkeit der
|
||||
Diagnosevariablen. Das Ein- oder Ausblenden veraendert daher nicht das
|
||||
Regelverhalten.
|
||||
|
||||
## Managerkommunikation
|
||||
|
||||
Das Modul implementiert
|
||||
`VerbraucherSchnittstelle::ManagerdatenEmpfangen()` und verwendet
|
||||
[ausschliesslich den Nachrichtenvertrag `4.0`](../../Schnittstelle.md). Eine
|
||||
separate Manager-ID wird nicht konfiguriert. Das Modul akzeptiert Vorgaben nur
|
||||
von einem Manager, in dessen manueller oder automatischer
|
||||
Verbraucherzuordnung die Ladestation aktiv eingetragen ist.
|
||||
|
||||
An den Manager werden neben den gemeinsamen Feldern diese Zustaende gemeldet:
|
||||
|
||||
- `Sollleistung_W`
|
||||
- `FahrzeugVerbunden`
|
||||
- `FahrzeugGeladen`
|
||||
- `Fahrzeugstatus`
|
||||
- `Phasenzahl`
|
||||
- `Ladestrom_A`
|
||||
- `Ladefreigabe`
|
||||
- `Solarladen`
|
||||
- `Ladefehler` mit Stoertext
|
||||
|
||||
Eine Sollleistung wird nur angenommen, wenn sie im zuletzt berechneten
|
||||
Leistungsangebot enthalten ist. Nach `VorgabeTimeout` ohne Erneuerung wird sie
|
||||
ungueltig. Statusaenderungen werden kurz verzoegert und zusaetzlich alle
|
||||
`Meldeintervall` Sekunden an alle zugeordneten Manager gemeldet.
|
||||
|
||||
## Instanzstatus und Fehlerbehandlung
|
||||
|
||||
| Status | Bedeutung |
|
||||
| ---: | --- |
|
||||
| `102` | Konfiguration und letzte Geraetekommunikation sind gueltig. |
|
||||
| `201` | Konfiguration ungueltig, beispielsweise fehlende Adresse oder Pico-Zugangsdaten. |
|
||||
| `202` | Geraetekommunikation oder Antwortauswertung fehlgeschlagen. |
|
||||
|
||||
Bei einem Fehler meldet das Modul `Verfuegbar=false`,
|
||||
`AenderungMoeglich=false` und setzt `Stoerung` sowie `Stoertext`. HTTP-Anfragen
|
||||
verwenden 5 Sekunden Verbindungs- und 10 Sekunden Gesamt-Timeout. Antworten ab
|
||||
HTTP-Status 400 sowie unvollstaendige oder ungueltige JSON-Antworten gelten als
|
||||
Kommunikationsfehler.
|
||||
|
||||
## Installation und Inbetriebnahme
|
||||
|
||||
1. Im IP-Symcon Module Control den Testing-Branch `develop` der Bibliothek
|
||||
`https://git.belevo.ch/ENELIX/Enelix-EMS.git` installieren oder
|
||||
aktualisieren.
|
||||
2. Unter **Instanz hinzufuegen** nach **Ladestation Stand-Alone** suchen und
|
||||
eine Instanz anlegen.
|
||||
3. Den Geraetetyp auswaehlen und die dazugehoerigen Verbindungsdaten eintragen:
|
||||
bei go-e nur IP-Adresse oder Hostname, bei Pico Geraete-ID, Seriennummer,
|
||||
Benutzername und Passwort.
|
||||
4. Den maximal zulaessigen Ladestrom der Installation zwischen 6 und 32 A
|
||||
einstellen. Diese Grenze ersetzt keine elektrische Absicherung.
|
||||
5. `Abfrageintervall`, `Meldeintervall`, `VorgabeTimeout` sowie die beiden
|
||||
Prioritaeten festlegen.
|
||||
6. Die gewuenschten Startwerte fuer `Ladefreigabe` und `Solarladen` setzen.
|
||||
7. Fuer die Erstinbetriebnahme `EinstellungenInVisu`,
|
||||
`DiagnosevariablenAnzeigen` und bei Bedarf `LoggingEin` aktivieren.
|
||||
8. Die Instanz im Manager manuell aktiv zuordnen oder bei automatischer Suche
|
||||
in der gefundenen Liste aktivieren.
|
||||
9. Zuerst ohne Fahrzeug kontrollieren, ob die Statusabfrage fehlerfrei ist.
|
||||
Danach unter Aufsicht ein Fahrzeug verbinden und `Aktiv` einschalten.
|
||||
|
||||
Fuer go-e muss IP-Symcon das Geraet im lokalen Netz per HTTP erreichen koennen.
|
||||
Fuer Pico ist ausgehender HTTPS-Zugriff auf `api.smart-me.com` erforderlich.
|
||||
Passwoerter gehoeren ausschliesslich in das dafuer vorgesehene Passwortfeld und
|
||||
niemals in Repository-Dateien, Skripte oder Screenshots.
|
||||
|
||||
## Abnahmecheckliste
|
||||
|
||||
- Die Instanz erreicht Status `102`, und `Stoerung` bleibt `false`.
|
||||
- Ohne Fahrzeug sind `FahrzeugVerbunden=false`, `Phasenzahl=0` und das
|
||||
Leistungsangebot `[0]`.
|
||||
- Nach dem Anstecken wird das Fahrzeug erkannt und bei unbekannten Phasen
|
||||
zunaechst der Mindeststrom von 6 A zur Erkennung angefordert.
|
||||
- Nach der Erkennungszeit werden eine oder drei Phasen relativ zum Pruefstrom
|
||||
gespeichert. Bei go-e koennen die einzelnen Phasenstroeme die Erkennung
|
||||
bereits vorher abschliessen.
|
||||
- Die erkannte Phasenzahl bleibt bei einer anschliessenden Ladepause stabil.
|
||||
- Die gemessene Leistung wird plausibel in `Istleistung` und `Ladestrom`
|
||||
abgebildet.
|
||||
- `Aktiv=false` oder `Ladefreigabe=false` stoppt die Ladestation.
|
||||
- PV mit `Solarladen=true` bietet `0 W` und die Ladestufen an.
|
||||
- PV mit `Solarladen=false` bietet nur die maximale Leistung an.
|
||||
- Peak mit `Solarladen=true` stoppt die Ladestation und bietet nur `[0]` an.
|
||||
- Peak mit `Solarladen=false` bietet `0 W` und die Ladestufen an.
|
||||
- Eine Manager-Vorgabe ausserhalb des gemeldeten Angebots wird abgewiesen.
|
||||
- Leistungswechsel-, Mindest-Ein- und Mindest-Aus-Zeiten werden im gemeldeten
|
||||
Angebot sichtbar und vom Manager eingehalten.
|
||||
- Nach Ablauf des Vorgabe-Timeouts gilt wieder das lokale Ersatzverhalten.
|
||||
- Im Debug-Protokoll und in `LetzterGeraetebefehl` erscheinen keine
|
||||
Zugangsdaten.
|
||||
|
||||
## Fehlersuche
|
||||
|
||||
- Status `201`: Geraetetyp und Pflichtfelder kontrollieren. Bei go-e darf die
|
||||
Adresse kein Protokoll, keinen Pfad und keine Parameter enthalten. Bei Pico
|
||||
muessen alle vier Zugangsfelder befuellt sein.
|
||||
- Status `202`: Erreichbarkeit, DNS, lokale Firewall und API-Antwort pruefen.
|
||||
`Stoertext` enthaelt den konkreten Kommunikations- oder JSON-Fehler.
|
||||
- Fahrzeug wird nicht erkannt: Rohstatus der Geraete-API kontrollieren. Der
|
||||
Adapter erwartet bei go-e `car` und `nrg[11]`, bei Pico `State` und
|
||||
`ActiveChargingPower`.
|
||||
- Phasenzahl bleibt laenger auf `0`: Waehrend der Erkennungsphase muessen
|
||||
Ladestation und Fahrzeug den angeforderten Maximalstrom tatsaechlich
|
||||
freigeben. Bei Pico muss fuer eine sichere Dreiphasenerkennung waehrend der
|
||||
Erkennung mehr als 7500 W anliegen; go-e kann zusaetzlich anhand der
|
||||
einzelnen Phasenstroeme erkennen.
|
||||
- Manager-Vorgabe wird abgewiesen: aktive Zuordnung im Manager sowie
|
||||
`LeistungsangebotDiagnose` und die Betriebsart kontrollieren.
|
||||
|
||||
## Tests
|
||||
|
||||
Die Adaptertests verwenden einen injizierten Fake-HTTP-Transport. Dadurch
|
||||
werden fuer alle drei Geraetevarianten Statusantworten, URL, HTTP-Methode,
|
||||
Authentisierung und Steueraufrufe geprueft, ohne ein reales Geraet anzusprechen.
|
||||
Der Symcon-Funktionstest nutzt den internen, nicht im Konfigurationsformular
|
||||
sichtbaren `Testmodus` und prueft zusaetzlich Fahrzeug-/Phasenerkennung,
|
||||
die vier PV-/Peak-Angebote sowie die Umschaltung ueber den `Solarladen`-Button.
|
||||
Die Unit- und Adaptertests verwenden einen injizierten Fake-HTTP-Transport.
|
||||
Damit werden fuer go-e alt, go-e Gemini und smart-me Pico Statusantworten,
|
||||
URL, HTTP-Methode, Authentisierung und Steueraufrufe ohne reale Hardware
|
||||
geprueft.
|
||||
|
||||
Der Symcon-Funktionstest verwendet den internen `Testmodus` mit simulierten
|
||||
API-Antworten. Er prueft alle drei Geraetevarianten, die Mindeststrom-Probe
|
||||
beim Anstecken, stabile Phasen waehrend einer Ladepause, die Schaltsperren,
|
||||
Fahrzeugstrom-Erkennung, die Leistungsangebote in PV und Peak sowie die
|
||||
Umschaltung ueber den `Solarladen`-Button.
|
||||
|
||||
PHP-Syntax, Struktur- und Unit-Tests des gesamten Repositorys:
|
||||
|
||||
```bash
|
||||
composer check
|
||||
```
|
||||
|
||||
Funktionstest nur fuer die Ladestation gegen IP-Symcon 8:
|
||||
|
||||
```bash
|
||||
tests/Symcon/bin/run-symcon-tests.sh single LadestationStandAlone
|
||||
```
|
||||
|
||||
Vollstaendiger Symcon-Modultestlauf:
|
||||
|
||||
```bash
|
||||
tests/Symcon/bin/run-symcon-tests.sh all
|
||||
```
|
||||
|
||||
Aufbau, Testvertrag und Ergebnisdateien sind unter
|
||||
[`docs/testing/README.md`](../../testing/README.md) beschrieben.
|
||||
|
||||
+256
-16
@@ -8,15 +8,28 @@ kommen aus den Verbrauchermeldungen.
|
||||
|
||||
## Variablen
|
||||
|
||||
Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netzleistung`.
|
||||
Die drei Regelungsvariablen `Aktiv`, `Betriebsart` und `Netzleistung` sind immer sichtbar. Die vom Manager gefuehrten Mess- und Energievariablen werden immer angelegt und archiviert, koennen aber gemeinsam ausgeblendet werden.
|
||||
|
||||
| Ident | Typ / Zugriff | Beschreibung |
|
||||
| --- | --- | --- |
|
||||
| `Aktiv` | Boolean / bedienbar | Regelung ein/aus; Start `false`. |
|
||||
| `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. |
|
||||
| `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. |
|
||||
| `PVLeistungArchiv` | Float / Logging | Normalisierte PV-Leistung in W. |
|
||||
| `HausverbrauchLeistungArchiv` | Float / Logging | Normalisierter Hausverbrauch in W. |
|
||||
| `NetzleistungArchiv` | Float / Logging | Normalisierte Netzleistung in W; positiv Bezug, negativ Einspeisung. |
|
||||
| `BatterieleistungArchiv` | Float / Logging | Batterieleistung in W; positiv Laden, negativ Entladen. |
|
||||
| `PVEnergie` | Float / Zaehler | Integrierte PV-Erzeugung in kWh. |
|
||||
| `Hausenergie` | Float / Zaehler | Integrierter Hausverbrauch in kWh. |
|
||||
| `NetzbezugEnergie` | Float / Zaehler | Integrierter Netzbezug in kWh. |
|
||||
| `EinspeisungEnergie` | Float / Zaehler | Integrierte Einspeisung in kWh. |
|
||||
| `BatterieLadenEnergie` | Float / Zaehler | Integrierte Batterieladung in kWh. |
|
||||
| `BatterieEntladenEnergie` | Float / Zaehler | Integrierte Batterieentladung in kWh. |
|
||||
| `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. |
|
||||
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. |
|
||||
| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. |
|
||||
| `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. |
|
||||
| `Abregelbedarf` | Float / Logging | Aktuell erforderliche PV-Leistungsreduktion in W. |
|
||||
| `Wechselrichterstatus` | String/JSON / Logging | Verteilte Grenzen und Zustand der PV-Regelung. |
|
||||
| `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. |
|
||||
| `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. |
|
||||
| `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. |
|
||||
@@ -47,12 +60,40 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
|
||||
| `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. |
|
||||
| `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. |
|
||||
| `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. |
|
||||
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
|
||||
| `EinspeisebegrenzungAktiv` | Boolean | `false`; gemeinsame Exportgrenze am Netzanschlusspunkt aktivieren. |
|
||||
| `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. |
|
||||
| `PrognoseAktiv` | Boolean | `false`; Prognosetelemetrie und Topologiesynchronisation mit `forecast_pv` plus `forecast_load` oder `grid_schedule` aktivieren. |
|
||||
| `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. |
|
||||
| `NetzbezugEnergieVariableID` | Integer | Optionaler kumulativer Netzbezugszaehler. |
|
||||
| `NetzeinspeisungEnergieVariableID` | Integer | Optionaler kumulativer Einspeisezaehler. |
|
||||
| `NetzbezugEnergiefaktor`, `NetzeinspeisungEnergiefaktor` | Float | Umrechnung der Netzenergiezaehler nach kWh. |
|
||||
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Nennleistung, Leistungs- und Erzeugungsenergiemessung sowie optionalem Begrenzungsregister. |
|
||||
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
|
||||
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Kopplung, Leistung, SOC sowie Lade- und Entladeenergiezaehlern. |
|
||||
| `PrognoseSendeintervall` | Integer | `60` s; Intervall fuer den Upload aktueller Messwerte, zulaessig sind 60 bis 3600 Sekunden. |
|
||||
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
|
||||
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
||||
| `SDLAktiv` | Boolean | `false`; separat gemessene SDL-/Regelenergie in der Anlagenbilanz beruecksichtigen. Keine SDL-Steuerung. |
|
||||
| `SDLLeistungVariableID` | Integer | `0`; bei aktiver SDL erforderliche numerische Istleistungsquelle. |
|
||||
| `SDLLeistungsfaktor` | Float | `1`; Umrechnung nach W, positiv Laden/Bezug, negativ Entladen/Abgabe. Fuer kW `1000`, bei umgekehrtem Vorzeichen negativ. |
|
||||
| `SDLSOCVariableID` | Integer | `0`; optionaler separater SDL-Ladezustand in Prozent, 0 bis 100. |
|
||||
| `SDLEnergieflussAnzeigen` | Boolean | `false`; SDL mit optionalem SOC als eigenen Speicher im aktivierten Energiefluss anzeigen. |
|
||||
| `SDLDiagrammeAnzeigen` | Boolean | `false`; SDL-Leistung, Lade-/Entladeenergie und optionalen SOC in aktivierten Diagrammen anzeigen. |
|
||||
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
||||
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
|
||||
| `DiagnosevariablenAnzeigen` | Boolean | `false`; zusätzliche Diagnosevariablen anlegen oder entfernen. |
|
||||
| `LoggingEin` | Boolean | `false`; laufende Meldungen im Debug-Fenster ausgeben. |
|
||||
| `EnergieaufzeichnungAktiv` | Boolean | `true`; managergefuehrte Leistungs- und Energieaufzeichnung aktivieren. |
|
||||
| `MesswerteAnzeigen` | Boolean | `false`; einzelne Manager-Messwerte in der Objektstruktur anzeigen. |
|
||||
| `EnergyPieAnzeigen` | Boolean | `false`; Energy Pie unter dem Manager anlegen oder entfernen. |
|
||||
| `EnergiediagrammeAnzeigen` | Boolean | `false`; native Leistungs- und Energiediagramme anlegen oder entfernen. |
|
||||
| `FunFactsAnzeigen` | Boolean | `false`; responsive Energy Facts anlegen oder entfernen. |
|
||||
| `EnergieflussAnzeigen` | Boolean | `false`; native Energy Distribution anlegen oder entfernen. |
|
||||
| `LeistungsaufzeichnungMinuten` | Auswahl | `1`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
|
||||
| `LeistungLoeschenMonate` | Integer | `12`; Leistungswerte nach zwoelf Monaten loeschen, `0` deaktiviert die Loeschung. |
|
||||
| `EnergieaufzeichnungMinuten` | Auswahl | `5`; Verdichtung auf 1 Minute, 5 Minuten oder 1 Stunde. |
|
||||
| `EnergieVerdichtenMonate` | Integer | `12`; Energiezaehler nach zwoelf Monaten auf Tageswerte verdichten. |
|
||||
| `EnergieLoeschenMonate` | Integer | `0`; Energiezaehler nicht loeschen. |
|
||||
|
||||
Es gibt keine Sollwertquellenauswahl und keine zweite Prioritätseinstellung im
|
||||
Manager. Im Modus `Aus` bleibt Solarladen aktiv, nur die Peak-Begrenzung entfällt.
|
||||
@@ -60,6 +101,168 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche
|
||||
ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut
|
||||
ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren.
|
||||
|
||||
## Energieaufzeichnung und Visualisierung
|
||||
|
||||
Sobald Netzleistung und mindestens eine PV-Istleistung in der Anlagentopologie
|
||||
konfiguriert sind, tastet der Manager die Quellen minuetlich ab. PV-Leistung ist
|
||||
die Summe der den PV-Flaechen zugeordneten Wechselrichter. Batterieleistung und
|
||||
der kapazitaetsgewichtete SOC stammen aus den Batteriespeichern. Positive
|
||||
Batterieleistung bedeutet Laden, negative Entladen. Der Hausverbrauch wird als
|
||||
`PV + Netz - Batterie` bilanziert. Sind Netzbezugs-, Einspeise-, PV-Erzeugungs-
|
||||
sowie gegebenenfalls Batterie-Lade- und Entladezaehler vollstaendig hinterlegt,
|
||||
verwendet der Manager deren Deltas fuer die Energieaufzeichnung. Die Hausenergie
|
||||
wird dann als `PV + Netzbezug + Batterieentladung - Einspeisung - Batterieladung`
|
||||
berechnet. Ohne vollstaendigen Zaehlerdatensatz integriert der Manager weiterhin
|
||||
die Leistungswerte. Dieselben Quellen versorgen ohne zweite Eingabe auch die
|
||||
Prognosetelemetrie. Ausfallluecken ueber fuenf Minuten werden bei der
|
||||
Leistungsintegration nicht nachberechnet.
|
||||
|
||||
Batteriespeicher bleiben als physische Komponenten samt Messquellen in der
|
||||
Anlagentopologie. Ein zusaetzliches Batterieverbrauchermodul beschreibt dagegen
|
||||
Regelung, Leistungsangebot und Betriebszustand; beide Rollen sind bewusst
|
||||
getrennt.
|
||||
|
||||
Die eigenen Variablen werden automatisch im ersten Archive Control aktiviert.
|
||||
Leistungen verwenden die Standardaggregation, Energievariablen den Zaehlermodus.
|
||||
Standardmaessig werden Leistungen auf eine Minute verdichtet und nach zwoelf
|
||||
Monaten geloescht. Energie wird auf fuenf Minuten verdichtet, nach zwoelf
|
||||
Monaten auf Tageswerte reduziert und nie geloescht. Aenderungen an diesen
|
||||
Regeln werden idempotent ueber die Archiv-API gesetzt und reaggregiert.
|
||||
|
||||
Vier Schalter im Bereich `Energieaufzeichnung` verwalten die
|
||||
Visualisierungen idempotent. Beim Einschalten werden die Objekte direkt unter
|
||||
dem Manager angelegt oder aktualisiert. Beim Ausschalten werden ausschliesslich
|
||||
die vom Manager anhand ihrer festen Kennung und ihres Typs erkannten Objekte
|
||||
entfernt beziehungsweise die native Energy Distribution ausgeblendet:
|
||||
|
||||
- Energy Pie aus Enelix Utils mit den vier relevanten Energiezaehlern,
|
||||
- zwei native IP-Symcon-Diagrammmedien fuer Leistungen und Energien,
|
||||
- responsive Energy Facts mit Solar-, Netz-, Haus- und Vergleichswerten,
|
||||
- native Energy Distribution fuer PV, Netz, Haus und Batterie.
|
||||
|
||||
## SDL / Regelenergie
|
||||
|
||||
`SDL aktiv` blendet die Quellen- und Anzeigeauswahl ein. Die SDL-Istleistung
|
||||
beschreibt einen **separat gemessenen AC-Zweig am selben Netzanschlusspunkt**.
|
||||
Sie darf weder in der normalen Batterieleistung noch in einer PV-Leistung
|
||||
enthalten sein. Eine identische Variablen-ID fuer SDL und Netz/PV/Batterie wird
|
||||
abgewiesen. Ueberschneidungen in extern gebildeten Summen muss die
|
||||
Anlagenkonfiguration ausschliessen. Ein gemeinsamer Speicher mit nur einem
|
||||
Gesamtleistungsmesswert kann damit nicht in EMS- und SDL-Anteile zerlegt werden.
|
||||
|
||||
Bei aktiver SDL lautet die Hauslast `max(0, PV + Netz - Batterie - SDL)`.
|
||||
Die Korrektur geschieht vor der Begrenzung auf null und unabhaengig davon,
|
||||
ob SDL angezeigt wird. Diese Hauslast wird an den Prognosedienst gesendet;
|
||||
Netzleistung und der kapazitaetsgewichtete SOC der normalen Batterien bleiben
|
||||
unveraendert. SDL-SOC ersetzt keinen Batterie-SOC und wird nicht als
|
||||
planbare EMS-Speicherkapazitaet exportiert. Ungueltige SDL-Leistungswerte
|
||||
unterbrechen die Messaufzeichnung und den jeweiligen Telemetrieversand,
|
||||
statt als null in die Hauslast einzugehen. Der SDL-Status ist bei aktiver
|
||||
SDL auch ohne eingeschaltete Diagnosevariablen sichtbar.
|
||||
|
||||
`SDLLeistungArchiv` zeichnet Watt auf, `SDLSOCArchiv` gueltige Prozentwerte.
|
||||
`SDLLadenEnergie` und `SDLEntladenEnergie` integrieren die getrennten Richtungen
|
||||
in kWh. Die Abtastung erfolgt wie bei den anderen Leistungen minuetlich, nicht
|
||||
als abrechnungsgenaue SDL-Energiemessung. Auch bei vorhandenen physikalischen
|
||||
Energiezaehlern wird die Hausenergie um die im selben Intervall integrierte
|
||||
SDL-Ladung vermindert und um SDL-Entladung erhoeht. Bei Messluecken ueber
|
||||
fuenf Minuten, Quellenwechseln oder Zaehlerresets wird keine unvollstaendige
|
||||
Hausenergie nachberechnet; Historie und bereits erfasste Zaehler bleiben erhalten.
|
||||
|
||||
Die Anzeigeoptionen wirken beim Anwenden direkt auf die vorhandenen
|
||||
Manager-Visualisierungen. Nur die eigenen SDL-Knoten und -Datensaetze werden
|
||||
ergaenzt oder entfernt. Andere Knoten, Farben, Datensaetze und sonstige
|
||||
Anpassungen bleiben bestehen. Im Energiefluss verwendet SDL dieselbe W/kW-
|
||||
Einheit wie die anderen Manager-Knoten; ein ungueltiger SOC wird dort nicht
|
||||
als aktueller Zusatzwert angezeigt. Ohne separaten SDL-Knoten enthaelt der
|
||||
Manager-Sammelknoten Batteriespeicher die normale Batterie plus SDL, damit
|
||||
die dargestellte Leistungsbilanz vollstaendig bleibt. Im Leistungsdiagramm
|
||||
erscheint die signierte SDL-Istleistung, im Energiediagramm erscheinen Laden
|
||||
und Entladen getrennt. SOC verwendet in beiden Diagrammen eine eigene rechte
|
||||
Prozentachse. Die allgemeinen Schalter fuer Energiefluss, Diagramme und
|
||||
Energieaufzeichnung bleiben erforderlich.
|
||||
|
||||
Migration: SDL bleibt nach dem Modulupdate standardmaessig aus. Bestehende
|
||||
Installationen behalten damit ihre bisherige Bilanz. Erst Quellen und
|
||||
Vorzeichen pruefen, SDL aktivieren und anwenden. Historische Hauslasten oder
|
||||
bereits trainierte Prognosen werden nicht rueckwirkend korrigiert; neue
|
||||
Prognoseeingaben sind ab Aktivierung bereinigt. Ausschalten entfernt nur
|
||||
SDL-Anzeigen, nicht die aufgezeichnete Historie. Der alte `SDLAnschluss`
|
||||
bleibt lesbar; externe SDL-Regelung und Stellbefehle werden nicht veraendert.
|
||||
|
||||
Native Darstellungsvertraege: [Energy Distribution](https://www.symcon.de/en/service/documentation/module-reference/energy/energy-distribution/)
|
||||
und [Diagramme](https://www.symcon.de/en/service/documentation/basics/media/charts/).
|
||||
|
||||
## Anlagenweite Einspeisebegrenzung
|
||||
|
||||
Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der
|
||||
Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter
|
||||
werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable
|
||||
konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als
|
||||
absolute Leistung in Watt ausgegeben werden.
|
||||
|
||||
Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln
|
||||
verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der
|
||||
AC-Nennleistung auf alle angebotenen Wechselrichter. Jede gemessene Abweichung
|
||||
wird stufenlos nachgefuehrt; ein zusaetzliches Toleranzfenster wird nicht
|
||||
verwendet. Beim Abschalten des Managers oder der Funktion werden zuvor gesetzte
|
||||
Grenzen kontrolliert auf die jeweilige
|
||||
Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht
|
||||
geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig
|
||||
sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen.
|
||||
|
||||
Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und
|
||||
Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im
|
||||
Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses
|
||||
Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant.
|
||||
Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der
|
||||
harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder
|
||||
abgelaufener Fahrplan fuehrt automatisch zum konfigurierten
|
||||
`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig
|
||||
davon aktiv.
|
||||
|
||||
## Anlagentopologie und Prognoseexport
|
||||
|
||||
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
|
||||
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
|
||||
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Individuelle
|
||||
AC-Einspeise- und Bezugsgrenzen werden nicht erfasst, weil die Begrenzung
|
||||
anlagenweit am Netzanschlusspunkt erfolgt. Modulanzahl und Modulleistung sind
|
||||
optional und muessen gemeinsam gesetzt werden.
|
||||
|
||||
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
|
||||
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
|
||||
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
|
||||
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
|
||||
beanspruchen.
|
||||
|
||||
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
|
||||
die drei Komponentenlisten, gemeinsame AC-Nennleistungen und aggregierte Summen
|
||||
fuer die schrittweise Prognoseanbindung. Interne Symcon-Variablen-IDs werden
|
||||
nicht exportiert. Tarif- und Variantenparameter bleiben im Prognoseportal. Bei
|
||||
aktivierter Prognose wird eine nichtleere Topologie beim Speichern automatisch ueber
|
||||
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
|
||||
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
|
||||
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
|
||||
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
|
||||
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
|
||||
EMS-Regelung aber nicht.
|
||||
|
||||
Der Forecast-Schalter zeigt direkt an, ob fuer die Manager-ID eine passende
|
||||
Kombination `forecast_pv` plus `forecast_load` oder `grid_schedule` vorhanden ist. Nach der Aktivierung sendet der
|
||||
Manager PV-Leistung, berechneten Hausverbrauch, Netzleistung und Batterie-SOC
|
||||
mit UTC-Zeitstempel im eingestellten Sendeintervall. Dieses Intervall bestimmt
|
||||
also, wie oft aktuelle Messwerte zum Prognosedienst hochgeladen werden; es ist
|
||||
kein Regelintervall. Das Minimum von 60 Sekunden
|
||||
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
|
||||
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
|
||||
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
|
||||
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
|
||||
Lizenzpruefung neu an.
|
||||
|
||||
Die Entscheidung und ihre Alternativen sind in
|
||||
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
||||
|
||||
## Lizenzierung
|
||||
|
||||
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
|
||||
@@ -94,8 +297,11 @@ wird vom Lizenzserver abgelehnt.
|
||||
|
||||
| Berechtigung | Freigegebene Funktion |
|
||||
| --- | --- |
|
||||
| `manager_standard` | Manager-Grundregelung ohne Lastspitzenmodus. |
|
||||
| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. |
|
||||
| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. |
|
||||
| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. |
|
||||
| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. |
|
||||
| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. |
|
||||
| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. |
|
||||
|
||||
Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager
|
||||
mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak`
|
||||
@@ -111,11 +317,15 @@ bis `offlineUntil` verwendbar. Der aktuelle Entwicklungsvertrag setzt diesen
|
||||
Zeitpunkt ungefaehr 14 Tage nach Ausstellung. Danach sperrt der Manager die
|
||||
Regelung, bis der Lizenzserver wieder eine gueltige Antwort liefert.
|
||||
|
||||
Die Installations-ID und die Lease liegen in internen Instanzattributen. Bei
|
||||
einer Migration muss deshalb die vollstaendige Manager-Instanz mitsamt ihren
|
||||
Die Installations-ID und die Lease liegen in internen Instanzattributen. Die
|
||||
ID wird erst nach dem Laden bestehender Attribute initialisiert und bleibt bei
|
||||
Modulupdates, Modul-Neuladen und einem normalen Neustart unveraendert. Bei einer
|
||||
Migration muss trotzdem die vollstaendige Manager-Instanz mitsamt ihren
|
||||
Attributen uebernommen werden. Eine neu erzeugte Instanz erhaelt eine andere
|
||||
Installations-ID und kann einen bereits gebundenen Code nicht selbststaendig
|
||||
uebertragen.
|
||||
uebertragen. Wurde die ID mit einer aelteren Manager-Version bereits ungewollt
|
||||
geaendert, muss im Lizenzportal einmalig ein Ersatzcode erzeugt und an die nun
|
||||
stabile ID gebunden werden.
|
||||
|
||||
### Status und Fehlerbehebung
|
||||
|
||||
@@ -128,6 +338,7 @@ uebertragen.
|
||||
| HTTP `429` | Zu viele Aktivierungsversuche; vor dem naechsten Versuch warten. |
|
||||
| `Lizenzserver nicht erreichbar` | DNS, Internetzugang, HTTPS und Systemzeit des Symcon-Systems pruefen. Eine bestehende Lease gilt nur bis `offlineUntil`. |
|
||||
| `Offline-Freigabe ist abgelaufen.` | Verbindung zum Lizenzserver wiederherstellen und Lizenz erneut pruefen. |
|
||||
| `Keine Prognoseberechtigung vorhanden` | Im Lizenzportal entweder PV-Ertragsprognose zusammen mit Verbrauchsprognose oder den Intelligenten Netzfahrplan ergaenzen. Anschliessend im Manager `Lizenz pruefen und binden` ausloesen, die Prognose aktivieren und die Konfiguration speichern. |
|
||||
|
||||
Fuer eine genauere Diagnose koennen die Diagnosevariablen eingeblendet werden.
|
||||
`Lizenzstatus` zeigt dann den aktuellen Zustand. Mit aktiviertem Debug-Logging
|
||||
@@ -151,14 +362,43 @@ 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.
|
||||
Nicht verfügbare oder aktuell angebotlose Verbraucher behalten ihre Leistung
|
||||
und erhalten keine neue Vorgabe. Jeder verfügbare, synchronisierte Verbraucher
|
||||
mit einem nicht leeren Angebot erhält dagegen einen Sollwert aus diesem Angebot.
|
||||
Das gilt auch bei `AenderungMoeglich=false`: Ein fixes Angebot wie `[11000]`
|
||||
wird mit genau `11000 W` zugeteilt.
|
||||
|
||||
Die Verbraucher werden nach der gemeldeten PV- beziehungsweise Peak-Priorität
|
||||
sortiert. Zuerst reserviert der Manager die richtungsneutralen erlaubten Werte
|
||||
sowie die gemessene Leistung fester oder gesperrter Verbraucher.
|
||||
|
||||
Positives Restbudget wird innerhalb jeder Priorität schrittweise verteilt:
|
||||
Zuerst kommt die kleinste nächste erreichbare **absolute Sollleistung**, nicht
|
||||
die kleinste Erhöhung. Bei gleicher nächster Sollleistung entscheidet die exakt
|
||||
gezählte bezogene Energie in Wh, danach stabil die Instanz-ID. Die bisherigen
|
||||
2-kWh-Gruppen werden für diese Vergabe nicht mehr verwendet. Vom Budget wird nur
|
||||
die Differenz zum bereits zugeteilten Sollwert abgezogen. Ein nicht finanzierbarer
|
||||
Schritt wird übersprungen; andere passende Schritte und danach tiefere
|
||||
Prioritäten können das verbleibende Budget nutzen.
|
||||
|
||||
Beispiel bei gleicher Priorität: A bietet `[0,100,200,400,800]` bei 10 kWh,
|
||||
B bietet `[0,100,110,500,780,1500]` bei 12 kWh. Die Zuteilungsfolge bei
|
||||
ausreichendem Budget ist `A100, B100, B110, A200, A400, B500, B780, A800, B1500`.
|
||||
Die zugehörigen Gesamtbudgets sind 100, 200, 210, 310, 510, 900, 1180, 1580 und
|
||||
2300 W. Energie entscheidet nur bei gleichen nächsten Leistungsstufen, nicht
|
||||
mehr über die vollständige Versorgung eines Verbrauchers vor allen anderen.
|
||||
|
||||
Ganzzahlige Bereiche folgen derselben Reihenfolge wie einzelne Wattstufen,
|
||||
werden aber über gemeinsame Leistungsniveaus effizient verarbeitet. Lücken
|
||||
werden nie durch unzulässige Werte geschlossen. Bei negativem Restbudget bleibt
|
||||
die bestehende Defizitregelung einschliesslich ihrer Energiegruppen unverändert.
|
||||
Eine verbleibende Abweichung wird im `Verbraucherstatus` dokumentiert.
|
||||
|
||||
Für das Update sind keine neuen Properties oder eine Migration nötig. Bestehende
|
||||
Energiezähler, Sperrzeiten und Freigaben bleiben erhalten. Nach dem Modulupdate
|
||||
gilt die neue Vergabe beim nächsten Regellauf. Vor einem Update einer laufenden
|
||||
Anlage Einstellungen sichern und die neuen Sollwerte kontrolliert prüfen;
|
||||
die Git-Veröffentlichung allein aktualisiert keine laufende Installation.
|
||||
|
||||
Die Betriebsart wechselt unterhalb des Solar-Sollwerts zu `PV` und oberhalb
|
||||
der wirksamen Lastspitzengrenze zu `Peak`. Zwischen den beiden Zielwerten
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# EMS-Module und Modulentwürfe
|
||||
|
||||
> Manager, Warmwassererwaermer, Batterie, Verbraucher 1-Stufig, Waermepumpe,
|
||||
> Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway sind als
|
||||
> installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner enthalten Besprechungsgrundlagen.
|
||||
> Manager, Warmwassererwaermer, Pufferspeicher, Batterie, Verbraucher 1-Stufig,
|
||||
> Waermepumpe, Ladestation Stand-Alone, Easee Gateway und Ladestation Gateway
|
||||
> sind als installierbare IP-Symcon-Module umgesetzt. Die weiteren Ordner
|
||||
> enthalten Besprechungsgrundlagen.
|
||||
|
||||
Alle steuerbaren Verbraucher verwenden die gemeinsamen Datenpunkte aus der
|
||||
[EMS-Schnittstelle](../Schnittstelle.md). In den Modul-READMEs stehen deshalb
|
||||
|
||||
+11
-11
@@ -42,9 +42,9 @@ werden, wenn sie vor dem Lauf nicht existierten.
|
||||
- `single`: genau die als Auswahl übergebenen Module
|
||||
- `affected`: die durch geänderte Pfade ermittelten Module
|
||||
|
||||
Verfuegbare Module: `EaseeGateway`, `LadestationGateway`,
|
||||
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`
|
||||
und `Warmwassererwaermer`.
|
||||
Verfuegbare Module: `Batterie`, `EaseeGateway`, `LadestationGateway`,
|
||||
`LadestationStandAlone`, `Manager`, `Pufferspeicher`, `VerbraucherEinStufig`,
|
||||
`Waermepumpe` und `Warmwassererwaermer`.
|
||||
|
||||
Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam.
|
||||
|
||||
@@ -85,15 +85,15 @@ Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log.
|
||||
|
||||
## CI-Regeln
|
||||
|
||||
Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich
|
||||
die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei
|
||||
gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite.
|
||||
Für `beta` ist immer `all` vorgeschrieben.
|
||||
Bei jedem Push laufen sämtliche PHPUnit-Tests und die PHP-Syntaxprüfung. Die
|
||||
Symcon-Modultests werden bis zur Verfügbarkeit eines geschützten Runners auf
|
||||
der isolierten IP-Symcon-8.0-Instanz des Agent-Servers ausgeführt. Vor jeder
|
||||
Übernahme nach `beta` ist ein erfolgreicher Lauf im Modus `all` verbindlich.
|
||||
Der erzeugte JSON- und JUnit-Bericht gehört zum Freigabenachweis.
|
||||
|
||||
Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`,
|
||||
lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten
|
||||
Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber
|
||||
nicht technisch durchsetzbar.
|
||||
Eine spätere CI-Automatisierung benötigt einen geschützten Runner mit dem Label
|
||||
`symcon-8`, lokalem Zugriff auf die isolierte IP-Symcon-Instanz und den exakt
|
||||
zu prüfenden Repository-Stand.
|
||||
|
||||
## Checkliste für neue Module
|
||||
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
|
||||
Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symcon 8.x:
|
||||
|
||||
- Enelix Manager mit manueller Zuordnung,
|
||||
- Enelix Manager mit manueller Zuordnung von fünf steuerbaren Teilnehmern,
|
||||
- zwei mehrstufige Boiler (1,2/2,4 kW und 1,5/3,0 kW),
|
||||
- einen einstufigen Entfeuchter (0,9 kW),
|
||||
- simulierte PV-Erzeugung, Grundlast, Netzleistung und Boilertemperaturen,
|
||||
- native Symcon-Instanz `Energy Distribution` für den Energiefluss,
|
||||
- eigene Kachelansichten mit allen Laufzeitparametern je Boiler,
|
||||
- kombinierten Verlauf für PV-Leistung, Hausverbrauch und Netzleistung,
|
||||
- einen zweistufigen Pufferspeicher (1,8/3,6 kW) mit Heizkurve,
|
||||
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
|
||||
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
|
||||
Batteriespeicher für die Prognoseanbindung,
|
||||
- simulierte Istleistungs- und Leistungsbegrenzungsregister des Wechselrichters,
|
||||
- eine aktive anlagenweite Einspeisebegrenzung auf 3.000 W mit 100 W Toleranz,
|
||||
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
|
||||
60-Sekunden-Intervall,
|
||||
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
|
||||
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
|
||||
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
|
||||
- eigene Kachelansichten mit Laufzeitparametern für Boiler, Puffer und Batterie,
|
||||
- kombinierten Verlauf für PV-Leistung, Hausverbrauch, EMS-Leistung und Netzleistung,
|
||||
- kombinierten Verlauf der kumulierten PV-, Haus-, Bezugs- und Einspeiseenergie,
|
||||
- Archivierung der Leistungs-, Energie- und Temperaturwerte.
|
||||
|
||||
@@ -27,26 +36,60 @@ print_r($result);
|
||||
Ein weiterer Lauf aktualisiert dieselbe Anlage anhand stabiler Idents und erzeugt keine Duplikate.
|
||||
Bestehende, nicht zur Demo gehörende Objekte werden nicht verändert oder gelöscht.
|
||||
|
||||
Der Bootstrap hinterlegt absichtlich keinen Lizenzcode und überschreibt eine
|
||||
bereits konfigurierte Manager-Lizenz nicht. Bei einer neuen Anlage wird die
|
||||
Struktur vollständig erstellt; der Manager bleibt bis zur regulären
|
||||
Lizenzaktivierung im Konfigurationsformular auf Status 203. Solange der Manager
|
||||
nicht freigegeben ist, bleiben seine Zuordnung und die Verbraucher deaktiviert,
|
||||
damit keine Lizenzfehler protokolliert werden. Nach der Aktivierung den
|
||||
Bootstrap erneut ausführen; er ordnet dann alle fünf Teilnehmer zu und startet
|
||||
die Regelung.
|
||||
|
||||
Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
|
||||
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
|
||||
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
|
||||
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
|
||||
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
|
||||
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
|
||||
alle 60 Sekunden an den Enelix-Prognosedienst. Die Demo verwendet ein absolutes
|
||||
Wechselrichter-Leistungsregister in Watt. Der Manager begrenzt die tatsächliche
|
||||
PV-Produktion so, dass die Gesamtanlage höchstens 3.000 W einspeist; innerhalb
|
||||
einer Toleranz von 100 W bleibt der zuletzt gesetzte Stellwert bestehen. Der
|
||||
intelligente Netzfahrplan bleibt deaktiviert, solange dessen Backendfassung nur
|
||||
als ausstehende Version bereitliegt.
|
||||
|
||||
## Bedienung
|
||||
|
||||
In der Kachelvisualisierung öffnet die Startkategorie `Enelix 2 Demoanlage`.
|
||||
Unter `Simulation` lassen sich Tageslauf, Tageszeit, Geschwindigkeit,
|
||||
PV-Spitzenleistung, Bewölkung und Haus-Grundlast verändern. Der Manager verteilt
|
||||
den verfügbaren PV-Überschuss nach Priorität auf beide Boiler und den Entfeuchter.
|
||||
dynamische Profile, PV-Spitzenleistung, mittlere Bewölkung, Haus-Grundlast und
|
||||
eine ungeregelte Zusatzlast verändern. Bei aktivierten dynamischen Profilen
|
||||
entstehen reproduzierbare Morgen- und Abendspitzen, ziehende Bewölkung und ein
|
||||
Tagesgang der Außentemperatur. Der Manager verteilt den verfügbaren
|
||||
PV-Überschuss nach Priorität auf beide Boiler, Entfeuchter, Pufferspeicher und
|
||||
Batterie. Bei hoher PV-Erzeugung ist die Abregelung an den technischen Variablen
|
||||
`PV-Wechselrichter Istleistung` und `PV-Wechselrichter Leistungsgrenze` sowie an
|
||||
der auf etwa 3.000 W begrenzten Netzeinspeisung sichtbar.
|
||||
|
||||
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`
|
||||
und `Boiler 2` öffnen direkt sämtliche Bedien-, Status- und Diagnosevariablen,
|
||||
ohne zuerst das Instanz-Konfigurationsformular zu zeigen. Unter `Leistung und
|
||||
Energie` liegen die beiden kombinierten Zeitdiagramme und die Energiezähler.
|
||||
Die Energiesummen verwenden die beschleunigte Simulationszeit.
|
||||
`Übersicht` zeigt die native Symcon-Energieverteilung. Die Kacheln `Boiler 1`,
|
||||
`Boiler 2`, `Pufferspeicher` und `Batteriespeicher` öffnen direkt die jeweiligen
|
||||
Bedien-, Status- und Diagnosevariablen, ohne zuerst das
|
||||
Instanz-Konfigurationsformular zu zeigen. Positive Batterieleistung bedeutet
|
||||
Laden, negative Leistung Entladen. Unter `Leistung und Energie` liegen die
|
||||
beiden kombinierten Zeitdiagramme und die Energiezähler. Die Energiesummen,
|
||||
Temperaturmodelle und der Ladezustand verwenden die beschleunigte
|
||||
Simulationszeit.
|
||||
|
||||
## Validierung
|
||||
|
||||
- Alle vier Enelix-Instanzen müssen Status 102 besitzen.
|
||||
- Der Manager muss drei Verbraucher melden.
|
||||
- Bei ausreichendem PV-Überschuss müssen Verbraucher schalten.
|
||||
- Netzleistung, PV-Leistung, Hausverbrauch, Verbraucherleistungen, Energiesummen und Temperaturen werden archiviert.
|
||||
- Die native Energieverteilung muss sechs Knoten enthalten.
|
||||
- Die fünf Verbraucher müssen Status 102 besitzen; der Manager nach Lizenzaktivierung ebenfalls (vorher erwarteter Status 203).
|
||||
- Der Manager muss fünf Teilnehmer aus vier unterschiedlichen Modultypen melden.
|
||||
- Bei ausreichendem PV-Überschuss müssen thermische Verbraucher schalten und die Batterie laden.
|
||||
- Bei höherem verbleibendem PV-Überschuss muss das Wechselrichterregister die PV-Leistung so begrenzen, dass ungefähr 3.000 W ins Netz eingespeist werden.
|
||||
- Wechselrichter-Istleistung und Leistungsgrenze müssen in der technischen Kategorie vorhanden und im Manager zugeordnet sein.
|
||||
- Puffertemperatur, Außentemperatur und Batterie-Ladezustand müssen sich über den Tageslauf verändern.
|
||||
- Netzleistung, PV-Leistung, Hausverbrauch, EMS-Leistung, Einzelverbräuche, Speicherleistung, Energiesummen und Temperaturen werden archiviert.
|
||||
- Die native Energieverteilung muss acht Knoten enthalten.
|
||||
- Beide Diagrammkacheln müssen mindestens drei beschriftete Zeitreihen anzeigen.
|
||||
- Ein erneuter Installationslauf muss dieselbe Root-ID zurückgeben.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const ENELIX_DEMO_ROOT_IDENT = 'ENELIX_DEMO_2';
|
||||
const ENELIX_DEMO_MANAGER_MODULE = '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}';
|
||||
const ENELIX_DEMO_BOILER_MODULE = '{B7C54AF4-AD7D-4FE4-B75D-203693906251}';
|
||||
const ENELIX_DEMO_SINGLE_MODULE = '{15879A4E-D0C2-4495-83DE-46E1E462591E}';
|
||||
const ENELIX_DEMO_BUFFER_MODULE = '{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}';
|
||||
const ENELIX_DEMO_BATTERY_MODULE = '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}';
|
||||
const ENELIX_DEMO_DUMMY_MODULE = '{485D0419-BE97-4548-AA9C-C083EB82E61E}';
|
||||
const ENELIX_DEMO_TILE_MODULE = '{B5B875BB-9B76-45FD-4E67-2607E45B3AC4}';
|
||||
const ENELIX_DEMO_ARCHIVE_MODULE = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
|
||||
@@ -28,15 +30,17 @@ function enelixDemoInstall(): array
|
||||
|
||||
enelixDemoEnsureProfiles();
|
||||
|
||||
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 90);
|
||||
$legacyFlow = enelixDemoEnsureVariable($dashboard, 'Energiefluss', 'Energiefluss (Legacy)', 3, '~HTMLBox', '', 900);
|
||||
IPS_SetHidden($legacyFlow, true);
|
||||
$pvDisplay = enelixDemoEnsureVariable($dashboard, 'PVLeistung', 'PV-Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 20);
|
||||
$houseDisplay = enelixDemoEnsureVariable($dashboard, 'Hausverbrauch', 'Hausverbrauch gesamt', 2, 'ENELIX.Demo.PowerPositive', 0.0, 30);
|
||||
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'Geregelte Verbraucher', 2, 'ENELIX.Demo.PowerPositive', 0.0, 40);
|
||||
$loadDisplay = enelixDemoEnsureVariable($dashboard, 'Verbraucherleistung', 'EMS-Leistung inkl. Speicher', 2, 'ENELIX.Demo.Power', 0.0, 40);
|
||||
$grid = enelixDemoEnsureVariable($dashboard, 'Netzleistung', 'Netzleistung', 2, 'ENELIX.Demo.Power', 0.0, 50);
|
||||
$boiler1Power = enelixDemoEnsureVariable($dashboard, 'Boiler1Leistung', 'Boiler 1 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 60);
|
||||
$boiler2Power = enelixDemoEnsureVariable($dashboard, 'Boiler2Leistung', 'Boiler 2 Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 70);
|
||||
$dryerPower = enelixDemoEnsureVariable($dashboard, 'EntfeuchterLeistung', 'Entfeuchter Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
|
||||
$bufferPower = enelixDemoEnsureVariable($dashboard, 'PufferLeistung', 'Pufferspeicher Leistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 90);
|
||||
$batteryPower = enelixDemoEnsureVariable($dashboard, 'BatterieLeistung', 'Batterieleistung', 2, 'ENELIX.Demo.Power', 0.0, 100);
|
||||
|
||||
$powerChart = enelixDemoEnsureVariable($history, 'Leistungsdiagramm', 'Leistungsverlauf (Legacy)', 3, '~HTMLBox', '', 90);
|
||||
$energyChart = enelixDemoEnsureVariable($history, 'Energiediagramm', 'Kumulierte Energien (Legacy)', 3, '~HTMLBox', '', 100);
|
||||
@@ -50,9 +54,11 @@ function enelixDemoInstall(): array
|
||||
$auto = enelixDemoEnsureVariable($simulation, 'Automatik', 'Tageslauf automatisch', 0, '~Switch', true, 10);
|
||||
$hour = enelixDemoEnsureVariable($simulation, 'Tageszeit', 'Simulierte Tageszeit', 2, 'ENELIX.Demo.Hour', 8.0, 20);
|
||||
$speed = enelixDemoEnsureVariable($simulation, 'Geschwindigkeit', 'Minuten je Schritt', 1, 'ENELIX.Demo.Speed', 10, 30);
|
||||
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 7000.0, 40);
|
||||
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 50);
|
||||
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 60);
|
||||
$dynamic = enelixDemoEnsureVariable($simulation, 'DynamischeProfile', 'Dynamische Tagesprofile', 0, '~Switch', true, 40);
|
||||
$peak = enelixDemoEnsureVariable($simulation, 'PVSpitze', 'PV-Spitzenleistung', 2, 'ENELIX.Demo.PowerPositive', 9000.0, 50);
|
||||
$cloud = enelixDemoEnsureVariable($simulation, 'Bewoelkung', 'Mittlere Bewölkung', 1, 'ENELIX.Demo.Percent', 20, 60);
|
||||
$base = enelixDemoEnsureVariable($simulation, 'Grundlast', 'Mittlere Haus-Grundlast', 2, 'ENELIX.Demo.PowerPositive', 900.0, 70);
|
||||
$extraLoad = enelixDemoEnsureVariable($simulation, 'Zusatzlast', 'Ungeregelte Zusatzlast', 2, 'ENELIX.Demo.PowerPositive', 0.0, 80);
|
||||
|
||||
$actorScript = enelixDemoEnsureScript(
|
||||
$technical,
|
||||
@@ -61,6 +67,13 @@ function enelixDemoInstall(): array
|
||||
"<?php\nSetValue((int) \$_IPS['VARIABLE'], (bool) \$_IPS['VALUE']);\n",
|
||||
10
|
||||
);
|
||||
$registerScript = enelixDemoEnsureScript(
|
||||
$technical,
|
||||
'RegisterAktion',
|
||||
'Simulierte Register',
|
||||
"<?php\nSetValue((int) \$_IPS['VARIABLE'], \$_IPS['VALUE']);\n",
|
||||
15
|
||||
);
|
||||
$tickScript = enelixDemoEnsureScript(
|
||||
$technical,
|
||||
'SimulationTick',
|
||||
@@ -76,7 +89,7 @@ function enelixDemoInstall(): array
|
||||
30
|
||||
);
|
||||
|
||||
foreach ([$auto, $hour, $speed, $peak, $cloud, $base] as $variableId) {
|
||||
foreach ([$auto, $hour, $speed, $dynamic, $peak, $cloud, $base, $extraLoad] as $variableId) {
|
||||
IPS_SetVariableCustomAction($variableId, $controlScript);
|
||||
}
|
||||
|
||||
@@ -84,36 +97,59 @@ function enelixDemoInstall(): array
|
||||
|
||||
$temp1 = enelixDemoEnsureVariable($technical, 'Boiler1Temperatur', 'Boiler 1 Temperatur', 2, '~Temperature', 49.0, 100);
|
||||
$temp2 = enelixDemoEnsureVariable($technical, 'Boiler2Temperatur', 'Boiler 2 Temperatur', 2, '~Temperature', 47.0, 110);
|
||||
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 120);
|
||||
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 130);
|
||||
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 140);
|
||||
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 150);
|
||||
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 160);
|
||||
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 170);
|
||||
$bufferTemp = enelixDemoEnsureVariable($technical, 'PufferTemperatur', 'Puffertemperatur', 2, '~Temperature', 36.0, 120);
|
||||
$outsideTemp = enelixDemoEnsureVariable($technical, 'AussenTemperatur', 'Aussentemperatur', 2, '~Temperature', 6.0, 130);
|
||||
$boiler11 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe1', 'Boiler 1 Stufe 1', 0, '~Switch', false, 200);
|
||||
$boiler12 = enelixDemoEnsureVariable($technical, 'Boiler1Stufe2', 'Boiler 1 Stufe 2', 0, '~Switch', false, 210);
|
||||
$boiler21 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe1', 'Boiler 2 Stufe 1', 0, '~Switch', false, 220);
|
||||
$boiler22 = enelixDemoEnsureVariable($technical, 'Boiler2Stufe2', 'Boiler 2 Stufe 2', 0, '~Switch', false, 230);
|
||||
$dryContact = enelixDemoEnsureVariable($technical, 'EntfeuchterKontakt', 'Entfeuchter Kontakt', 0, '~Switch', false, 240);
|
||||
$buffer1 = enelixDemoEnsureVariable($technical, 'PufferStufe1', 'Puffer Stufe 1', 0, '~Switch', false, 250);
|
||||
$buffer2 = enelixDemoEnsureVariable($technical, 'PufferStufe2', 'Puffer Stufe 2', 0, '~Switch', false, 260);
|
||||
$batteryMaxCharge = enelixDemoEnsureVariable($technical, 'BatterieMaxLaden', 'Batterie max. laden', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 300);
|
||||
$batteryMaxDischarge = enelixDemoEnsureVariable($technical, 'BatterieMaxEntladen', 'Batterie max. entladen', 2, 'ENELIX.Demo.PowerPositive', 3500.0, 310);
|
||||
$batterySoc = enelixDemoEnsureVariable($technical, 'BatterieLadezustand', 'Batterie Ladezustand', 2, 'ENELIX.Demo.PercentFloat', 52.0, 320);
|
||||
$batteryActual = enelixDemoEnsureVariable($technical, 'BatterieIstleistung', 'Batterie Istleistung', 2, 'ENELIX.Demo.Power', 0.0, 330);
|
||||
$batteryManagement = enelixDemoEnsureVariable($technical, 'BatterieManagement', 'Batterie Managementregister', 1, '', 0, 340);
|
||||
$batteryMode = enelixDemoEnsureVariable($technical, 'BatterieModus', 'Batterie Modusregister', 1, '', 0, 350);
|
||||
$batteryRegisterPower = enelixDemoEnsureVariable($technical, 'BatterieRegisterLeistung', 'Batterie Leistungsregister', 2, 'ENELIX.Demo.PowerPositive', 0.0, 360);
|
||||
$inverterActual = enelixDemoEnsureVariable($technical, 'WechselrichterIstleistung', 'PV-Wechselrichter Istleistung', 2, 'ENELIX.Demo.PowerPositive', 0.0, 370);
|
||||
$inverterLimit = enelixDemoEnsureVariable($technical, 'WechselrichterLeistungsgrenze', 'PV-Wechselrichter Leistungsgrenze', 2, 'ENELIX.Demo.PowerPositive', max(0.0, (float) GetValue($peak)), 380);
|
||||
$chartUpdated = enelixDemoEnsureVariable($technical, 'LetztesDiagrammUpdate', 'Letztes Diagramm-Update', 1, '', 0, 390);
|
||||
IPS_SetHidden($chartUpdated, true);
|
||||
|
||||
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 300);
|
||||
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 310);
|
||||
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 320);
|
||||
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 330);
|
||||
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 340);
|
||||
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 350);
|
||||
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 360);
|
||||
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'Geregelte Verbraucher', 2, '~Power', 0.0, 370);
|
||||
$flowPv = enelixDemoEnsureVariable($technical, 'EnergieflussPVkW', 'PV-Leistung', 2, '~Power', 0.0, 500);
|
||||
$flowGrid = enelixDemoEnsureVariable($technical, 'EnergieflussNetzkW', 'Netzleistung', 2, '~Power', 0.0, 510);
|
||||
$flowBase = enelixDemoEnsureVariable($technical, 'EnergieflussGrundlastkW', 'Haus-Grundlast', 2, '~Power', 0.0, 520);
|
||||
$flowBoiler1 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler1kW', 'Boiler 1', 2, '~Power', 0.0, 530);
|
||||
$flowBoiler2 = enelixDemoEnsureVariable($technical, 'EnergieflussBoiler2kW', 'Boiler 2', 2, '~Power', 0.0, 540);
|
||||
$flowDryer = enelixDemoEnsureVariable($technical, 'EnergieflussEntfeuchterkW', 'Entfeuchter', 2, '~Power', 0.0, 550);
|
||||
$flowBuffer = enelixDemoEnsureVariable($technical, 'EnergieflussPufferkW', 'Pufferspeicher', 2, '~Power', 0.0, 560);
|
||||
$flowBattery = enelixDemoEnsureVariable($technical, 'EnergieflussBatteriekW', 'Batteriespeicher', 2, '~Power', 0.0, 570);
|
||||
$flowHouse = enelixDemoEnsureVariable($technical, 'DiagrammHausverbrauchkW', 'Hausverbrauch gesamt', 2, '~Power', 0.0, 580);
|
||||
$flowLoads = enelixDemoEnsureVariable($technical, 'DiagrammVerbraucherkW', 'EMS-Leistung inkl. Speicher', 2, '~Power', 0.0, 590);
|
||||
foreach ([
|
||||
$flowPv, $flowGrid, $flowBase, $flowBoiler1,
|
||||
$flowBoiler2, $flowDryer, $flowHouse, $flowLoads,
|
||||
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2,
|
||||
$flowDryer, $flowBuffer, $flowBattery, $flowHouse, $flowLoads,
|
||||
] as $flowVariableId) {
|
||||
IPS_SetHidden($flowVariableId, true);
|
||||
}
|
||||
|
||||
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact] as $actorId) {
|
||||
foreach ([$boiler11, $boiler12, $boiler21, $boiler22, $dryContact, $buffer1, $buffer2] as $actorId) {
|
||||
IPS_SetVariableCustomAction($actorId, $actorScript);
|
||||
}
|
||||
foreach ([$batteryManagement, $batteryMode, $batteryRegisterPower, $inverterLimit] as $registerId) {
|
||||
IPS_SetVariableCustomAction($registerId, $registerScript);
|
||||
}
|
||||
foreach ([$temp1, $temp2, $bufferTemp, $outsideTemp, $batteryMaxCharge, $batteryMaxDischarge, $batterySoc, $batteryActual, $inverterActual] as $sensorId) {
|
||||
SetValue($sensorId, GetValue($sensorId));
|
||||
}
|
||||
|
||||
$boiler1 = enelixDemoEnsureInstance($ems, 'Boiler1', 'Boiler 1 · 300 Liter', ENELIX_DEMO_BOILER_MODULE, 20);
|
||||
$boiler2 = enelixDemoEnsureInstance($ems, 'Boiler2', 'Boiler 2 · 500 Liter', ENELIX_DEMO_BOILER_MODULE, 30);
|
||||
$dryer = enelixDemoEnsureInstance($ems, 'Entfeuchter', 'Entfeuchter · 1-stufig', ENELIX_DEMO_SINGLE_MODULE, 40);
|
||||
$buffer = enelixDemoEnsureInstance($ems, 'Pufferspeicher', 'Pufferspeicher · Heizkurve', ENELIX_DEMO_BUFFER_MODULE, 50);
|
||||
$battery = enelixDemoEnsureInstance($ems, 'Batterie', 'Batteriespeicher · 10 kWh', ENELIX_DEMO_BATTERY_MODULE, 60);
|
||||
|
||||
enelixDemoConfigure($boiler1, [
|
||||
'Boilerfuehler_PT1' => $temp1,
|
||||
@@ -157,6 +193,52 @@ function enelixDemoInstall(): array
|
||||
'Mindestausschaltdauer' => 2,
|
||||
'Mindestlaufzeit' => 0,
|
||||
'PrioritaetPV' => 2,
|
||||
'PrioritaetPeak' => 3,
|
||||
'Meldeintervall' => 5,
|
||||
'VorgabeTimeout' => 30,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
]);
|
||||
enelixDemoConfigure($buffer, [
|
||||
'LeistungsStufen' => json_encode([
|
||||
['Stufe' => 1, 'Leistung' => 1800, 'Schaltkontakt_Stufe' => $buffer1],
|
||||
['Stufe' => 2, 'Leistung' => 3600, 'Schaltkontakt_Stufe' => $buffer2],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'PufferfuehlerVariableID' => $bufferTemp,
|
||||
'AussentemperaturVariableID' => $outsideTemp,
|
||||
'FusspunktVorlauftemperatur' => 34.0,
|
||||
'HeizkurvenSteigung' => 0.8,
|
||||
'HeizkurveMinimaltemperatur' => 28.0,
|
||||
'HeizkurveMaximaltemperatur' => 55.0,
|
||||
'Hysterese' => 4.0,
|
||||
'MindesttemperaturModus' => 0,
|
||||
'LastwechselSperrzeit' => 2,
|
||||
'TemperaturMaxAlter' => 30,
|
||||
'PuffertemperaturGlaetten' => false,
|
||||
'PrioritaetPV' => 3,
|
||||
'PrioritaetPeak' => 4,
|
||||
'Meldeintervall' => 5,
|
||||
'VorgabeTimeout' => 30,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
]);
|
||||
enelixDemoConfigure($battery, [
|
||||
'Batterietyp' => 2,
|
||||
'Batteriemanagement' => 2,
|
||||
'MaxLadeleistungVariableID' => $batteryMaxCharge,
|
||||
'MaxEntladeleistungVariableID' => $batteryMaxDischarge,
|
||||
'LadezustandVariableID' => $batterySoc,
|
||||
'NetzleistungVariableID' => $grid,
|
||||
'IstleistungVariableID' => $batteryActual,
|
||||
'ManagementRegisterVariableID' => $batteryManagement,
|
||||
'ModusRegisterVariableID' => $batteryMode,
|
||||
'LeistungsRegisterVariableID' => $batteryRegisterPower,
|
||||
'ReserveLadezustand' => 20.0,
|
||||
'MindestLadezustand' => 10.0,
|
||||
'LadezustandHysterese' => 3.0,
|
||||
'MesswertMaxAlter' => 30,
|
||||
'Aenderungssperre' => 0,
|
||||
'PrioritaetPV' => 4,
|
||||
'PrioritaetPeak' => 0,
|
||||
'Meldeintervall' => 5,
|
||||
'VorgabeTimeout' => 30,
|
||||
@@ -178,6 +260,8 @@ function enelixDemoInstall(): array
|
||||
enelixDemoEnergyNode($flowBoiler1, 'Boiler 1', 'consumer', $temp1),
|
||||
enelixDemoEnergyNode($flowBoiler2, 'Boiler 2', 'consumer', $temp2),
|
||||
enelixDemoEnergyNode($flowDryer, 'Entfeuchter', 'consumer'),
|
||||
enelixDemoEnergyNode($flowBuffer, 'Pufferspeicher', 'consumer', $bufferTemp),
|
||||
enelixDemoEnergyNode($flowBattery, 'Batteriespeicher', 'battery', $batterySoc),
|
||||
]);
|
||||
IPS_SetHidden($energyDistribution, false);
|
||||
$oldEnergyDistribution = enelixDemoFind($dashboard, 'Energieverteilung');
|
||||
@@ -188,34 +272,105 @@ function enelixDemoInstall(): array
|
||||
foreach ([$boiler1, $boiler2] as $boilerId) {
|
||||
IPS_RequestAction($boilerId, 'Mindesttemperatur', 42.0);
|
||||
IPS_RequestAction($boilerId, 'Maximaltemperatur', 62.0);
|
||||
IPS_RequestAction($boilerId, 'Aktiv', true);
|
||||
}
|
||||
IPS_RequestAction($dryer, 'Aktiv', true);
|
||||
|
||||
$manager = enelixDemoEnsureInstance($ems, 'Manager', 'Enelix Manager', ENELIX_DEMO_MANAGER_MODULE, 10);
|
||||
$assignment = array_map(
|
||||
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
|
||||
[$boiler1, $boiler2, $dryer]
|
||||
[$boiler1, $boiler2, $dryer, $buffer, $battery]
|
||||
);
|
||||
enelixDemoConfigure($manager, [
|
||||
$pvNennleistungKw = max(0.1, (float) GetValue($peak) / 1000.0);
|
||||
$wechselrichterKonfiguration = [[
|
||||
'ID' => 'demo-hybrid-1',
|
||||
'Name' => 'Demo-Hybridwechselrichter',
|
||||
'Typ' => 'hybrid',
|
||||
'ACNennleistung_kW' => $pvNennleistungKw,
|
||||
'IstleistungVariableID' => $inverterActual,
|
||||
'Istleistungsfaktor' => 1.0,
|
||||
'BegrenzungVariableID' => $inverterLimit,
|
||||
'Begrenzungsart' => 'watt',
|
||||
]];
|
||||
$pvKonfiguration = [[
|
||||
'ID' => 'demo-pv-1',
|
||||
'Name' => 'Demo-PV-Anlage',
|
||||
'DCLeistung_kWp' => $pvNennleistungKw,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'demo-hybrid-1',
|
||||
'MPPT' => 'A',
|
||||
'Modulanzahl' => 0,
|
||||
'Modulleistung_Wp' => 0.0,
|
||||
]];
|
||||
$batterieKonfiguration = [[
|
||||
'ID' => 'demo-battery-1',
|
||||
'Name' => 'Batteriespeicher 10 kWh',
|
||||
'Nennkapazitaet_kWh' => 10.0,
|
||||
'Nutzkapazitaet_kWh' => 10.0,
|
||||
'MaxLadeleistung_kW' => max(0.1, (float) GetValue($batteryMaxCharge) / 1000.0),
|
||||
'MaxEntladeleistung_kW' => max(
|
||||
0.1,
|
||||
(float) GetValue($batteryMaxDischarge) / 1000.0
|
||||
),
|
||||
'WechselrichterID' => 'demo-hybrid-1',
|
||||
'Kopplung' => 'hybrid',
|
||||
'LeistungVariableID' => $batteryActual,
|
||||
'Leistungsfaktor' => 1.0,
|
||||
'SOCVariableID' => $batterySoc,
|
||||
]];
|
||||
$managerProperties = [
|
||||
'NetzleistungVariableID' => $grid,
|
||||
'Netzleistungsfaktor' => 1.0,
|
||||
'MesswerteAnzeigen' => true,
|
||||
'EnergyPieAnzeigen' => true,
|
||||
'EnergiediagrammeAnzeigen' => true,
|
||||
'FunFactsAnzeigen' => true,
|
||||
'EnergieflussAnzeigen' => true,
|
||||
'PrognoseSendeintervall' => 60,
|
||||
'MesswertMaxAlter' => 10,
|
||||
'AutomatischeSuche' => false,
|
||||
'SuchbereichID' => $root,
|
||||
'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR),
|
||||
'VerbraucherZuordnung' => '[]',
|
||||
'KeepAlive' => 5,
|
||||
'VerbraucherTimeout' => 15,
|
||||
'SollwertSolarladen' => 0.0,
|
||||
'Umschaltdifferenz' => 0.0,
|
||||
'EinspeisebegrenzungAktiv' => true,
|
||||
'Einspeisegrenze' => 3000.0,
|
||||
'PrognoseAktiv' => false,
|
||||
'NetzfahrplanAktiv' => false,
|
||||
'AnlagenWechselrichter' => json_encode(
|
||||
$wechselrichterKonfiguration,
|
||||
JSON_THROW_ON_ERROR
|
||||
),
|
||||
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
|
||||
'AnlagenBatterien' => json_encode(
|
||||
$batterieKonfiguration,
|
||||
JSON_THROW_ON_ERROR
|
||||
),
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'LoggingEin' => false,
|
||||
]);
|
||||
|
||||
foreach ([$boiler1, $boiler2, $dryer] as $consumerId) {
|
||||
IPS_RequestAction($consumerId, 'Melden', true);
|
||||
];
|
||||
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
|
||||
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||
if ($managerReady) {
|
||||
$managerProperties['VerbraucherZuordnung'] = json_encode($assignment, JSON_THROW_ON_ERROR);
|
||||
enelixDemoConfigure($manager, $managerProperties, [102, 203]);
|
||||
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||
}
|
||||
IPS_RequestAction($manager, 'Aktiv', true);
|
||||
if (!$managerReady && IPS_GetProperty($manager, 'VerbraucherZuordnung') !== '[]') {
|
||||
$managerProperties['VerbraucherZuordnung'] = '[]';
|
||||
enelixDemoConfigure($manager, $managerProperties, [203]);
|
||||
}
|
||||
|
||||
$consumerIds = [$boiler1, $boiler2, $dryer, $buffer, $battery];
|
||||
foreach ($consumerIds as $consumerId) {
|
||||
IPS_RequestAction($consumerId, 'Aktiv', $managerReady);
|
||||
if ($managerReady) {
|
||||
IPS_RequestAction($consumerId, 'Melden', true);
|
||||
}
|
||||
}
|
||||
IPS_RequestAction($manager, 'Aktiv', $managerReady);
|
||||
IPS_RequestAction($manager, 'MesswerteAufzeichnen', true);
|
||||
|
||||
$boiler1List = enelixDemoEnsureInstance(
|
||||
$dashboard,
|
||||
@@ -231,14 +386,32 @@ function enelixDemoInstall(): array
|
||||
ENELIX_DEMO_DUMMY_MODULE,
|
||||
120
|
||||
);
|
||||
enelixDemoConfigure($boiler1List, []);
|
||||
enelixDemoConfigure($boiler2List, []);
|
||||
$bufferList = enelixDemoEnsureInstance(
|
||||
$dashboard,
|
||||
'PufferListe',
|
||||
'Pufferspeicher',
|
||||
ENELIX_DEMO_DUMMY_MODULE,
|
||||
130
|
||||
);
|
||||
$batteryList = enelixDemoEnsureInstance(
|
||||
$dashboard,
|
||||
'BatterieListe',
|
||||
'Batteriespeicher',
|
||||
ENELIX_DEMO_DUMMY_MODULE,
|
||||
140
|
||||
);
|
||||
foreach ([$boiler1List, $boiler2List, $bufferList, $batteryList] as $listId) {
|
||||
enelixDemoConfigure($listId, []);
|
||||
}
|
||||
enelixDemoLinkInstanceVariables($boiler1List, $boiler1, $temp1);
|
||||
enelixDemoLinkInstanceVariables($boiler2List, $boiler2, $temp2);
|
||||
enelixDemoLinkInstanceVariables($bufferList, $buffer, $bufferTemp, 'Puffertemperatur', 'SensorPuffertemperatur');
|
||||
enelixDemoLinkInstanceVariables($batteryList, $battery, $batterySoc, 'Ladezustand', 'SensorLadezustand');
|
||||
IPS_SetHidden($boiler1View, true);
|
||||
IPS_SetHidden($boiler2View, true);
|
||||
IPS_SetHidden($boiler1, true);
|
||||
IPS_SetHidden($boiler2, true);
|
||||
foreach ([$boiler1, $boiler2, $buffer, $battery] as $hiddenInstanceId) {
|
||||
IPS_SetHidden($hiddenInstanceId, true);
|
||||
}
|
||||
|
||||
foreach (['Boiler1Link', 'Boiler2Link'] as $oldLinkIdent) {
|
||||
$oldLink = enelixDemoFind($dashboard, $oldLinkIdent);
|
||||
@@ -248,22 +421,25 @@ function enelixDemoInstall(): array
|
||||
}
|
||||
|
||||
enelixDemoEnsureLink($dashboard, 'ManagerLink', 'Manager', $manager, 100);
|
||||
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 130);
|
||||
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 70);
|
||||
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 80);
|
||||
enelixDemoEnsureLink($dashboard, 'EntfeuchterLink', 'Entfeuchter', $dryer, 150);
|
||||
enelixDemoEnsureLink($simulation, 'Boiler1TemperaturLink', 'Boiler 1 Temperatur', $temp1, 100);
|
||||
enelixDemoEnsureLink($simulation, 'Boiler2TemperaturLink', 'Boiler 2 Temperatur', $temp2, 110);
|
||||
enelixDemoEnsureLink($simulation, 'PufferTemperaturLink', 'Puffertemperatur', $bufferTemp, 120);
|
||||
enelixDemoEnsureLink($simulation, 'AussenTemperaturLink', 'Aussentemperatur', $outsideTemp, 130);
|
||||
enelixDemoEnsureLink($simulation, 'BatterieLadezustandLink', 'Batterie Ladezustand', $batterySoc, 140);
|
||||
|
||||
enelixDemoConfigureArchive([
|
||||
$pvDisplay, $houseDisplay, $loadDisplay, $grid,
|
||||
$boiler1Power, $boiler2Power, $dryerPower,
|
||||
$boiler1Power, $boiler2Power, $dryerPower, $bufferPower, $batteryPower,
|
||||
$pvEnergy, $houseEnergy, $gridImportEnergy, $gridExportEnergy,
|
||||
$temp1, $temp2,
|
||||
$temp1, $temp2, $bufferTemp, $outsideTemp, $batterySoc, $batteryActual,
|
||||
$flowPv, $flowGrid, $flowBase, $flowBoiler1, $flowBoiler2, $flowDryer,
|
||||
$flowHouse, $flowLoads,
|
||||
$flowBuffer, $flowBattery, $flowHouse, $flowLoads,
|
||||
]);
|
||||
enelixDemoEnsureChart($history, 'LeistungsdiagrammNativ', 'Leistungsverlauf', [
|
||||
enelixDemoChartDataset($flowPv, 'PV', '#f4c542', '~Power'),
|
||||
enelixDemoChartDataset($flowHouse, 'Hausverbrauch', '#2bb673', '~Power'),
|
||||
enelixDemoChartDataset($flowLoads, 'Geregelte Verbraucher', '#2786ac', '~Power'),
|
||||
enelixDemoChartDataset($flowLoads, 'EMS-Leistung inkl. Speicher', '#2786ac', '~Power'),
|
||||
enelixDemoChartDataset($flowGrid, 'Netzleistung', '#e95d5d', '~Power'),
|
||||
], 10);
|
||||
enelixDemoEnsureChart($history, 'EnergiediagrammNativ', 'Kumulierte Energien', [
|
||||
@@ -279,7 +455,7 @@ function enelixDemoInstall(): array
|
||||
'rootId' => $root,
|
||||
'visualizationId' => enelixDemoFirstInstance(ENELIX_DEMO_TILE_MODULE),
|
||||
'managerId' => $manager,
|
||||
'consumerIds' => [$boiler1, $boiler2, $dryer],
|
||||
'consumerIds' => $consumerIds,
|
||||
'tickScriptId' => $tickScript,
|
||||
];
|
||||
}
|
||||
@@ -301,13 +477,24 @@ function enelixDemoTick(string $source = 'Manual'): void
|
||||
SetValue($hourId, $hour);
|
||||
}
|
||||
|
||||
$dynamic = (bool) GetValue(enelixDemoRequiredId($simulation, 'DynamischeProfile'));
|
||||
$environment = enelixDemoEnvironment(
|
||||
$hour,
|
||||
(float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast')),
|
||||
(int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung')),
|
||||
$dynamic
|
||||
);
|
||||
$base = $environment['base']
|
||||
+ (float) GetValue(enelixDemoRequiredId($simulation, 'Zusatzlast'));
|
||||
$peak = (float) GetValue(enelixDemoRequiredId($simulation, 'PVSpitze'));
|
||||
$cloud = (int) GetValue(enelixDemoRequiredId($simulation, 'Bewoelkung'));
|
||||
$base = (float) GetValue(enelixDemoRequiredId($simulation, 'Grundlast'));
|
||||
$sun = ($hour >= 6.0 && $hour <= 18.0)
|
||||
? max(0.0, sin(M_PI * ($hour - 6.0) / 12.0))
|
||||
: 0.0;
|
||||
$pv = $peak * $sun * (1.0 - (min(100, max(0, $cloud)) * 0.0075));
|
||||
$pvAvailable = $peak * $sun * (1.0 - ($environment['cloud'] * 0.0085));
|
||||
$inverterActual = enelixDemoRequiredId($technical, 'WechselrichterIstleistung');
|
||||
$inverterLimit = enelixDemoRequiredId($technical, 'WechselrichterLeistungsgrenze');
|
||||
$pv = enelixDemoLimitPvPower($pvAvailable, (float) GetValue($inverterLimit));
|
||||
SetValue($inverterActual, round($pv, 1));
|
||||
|
||||
$contacts = [
|
||||
'boiler11' => enelixDemoRequiredId($technical, 'Boiler1Stufe1'),
|
||||
@@ -315,15 +502,46 @@ function enelixDemoTick(string $source = 'Manual'): void
|
||||
'boiler21' => enelixDemoRequiredId($technical, 'Boiler2Stufe1'),
|
||||
'boiler22' => enelixDemoRequiredId($technical, 'Boiler2Stufe2'),
|
||||
'dryer' => enelixDemoRequiredId($technical, 'EntfeuchterKontakt'),
|
||||
'buffer1' => enelixDemoRequiredId($technical, 'PufferStufe1'),
|
||||
'buffer2' => enelixDemoRequiredId($technical, 'PufferStufe2'),
|
||||
'battery' => enelixDemoRequiredId($technical, 'BatterieIstleistung'),
|
||||
];
|
||||
$powerBefore = enelixDemoControlledPower($contacts);
|
||||
$temp1 = enelixDemoRequiredId($technical, 'Boiler1Temperatur');
|
||||
$temp2 = enelixDemoRequiredId($technical, 'Boiler2Temperatur');
|
||||
$bufferTemp = enelixDemoRequiredId($technical, 'PufferTemperatur');
|
||||
$outsideTemp = enelixDemoRequiredId($technical, 'AussenTemperatur');
|
||||
$batterySoc = enelixDemoRequiredId($technical, 'BatterieLadezustand');
|
||||
$batteryMaxCharge = enelixDemoRequiredId($technical, 'BatterieMaxLaden');
|
||||
$batteryMaxDischarge = enelixDemoRequiredId($technical, 'BatterieMaxEntladen');
|
||||
$previousBatteryPower = (float) GetValue($contacts['battery']);
|
||||
|
||||
SetValue($outsideTemp, round($environment['outside'], 2));
|
||||
SetValue($batteryMaxCharge, GetValue($batteryMaxCharge));
|
||||
SetValue($batteryMaxDischarge, GetValue($batteryMaxDischarge));
|
||||
if ($source === 'Timer') {
|
||||
enelixDemoUpdateTemperature($temp1, (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0), $minutes);
|
||||
enelixDemoUpdateTemperature($temp2, (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0), $minutes);
|
||||
enelixDemoUpdateTemperature(
|
||||
$temp1,
|
||||
(bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0),
|
||||
$minutes
|
||||
);
|
||||
enelixDemoUpdateTemperature(
|
||||
$temp2,
|
||||
(bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0),
|
||||
$minutes
|
||||
);
|
||||
enelixDemoUpdateBufferTemperature(
|
||||
$bufferTemp,
|
||||
(bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0),
|
||||
$environment['outside'],
|
||||
$minutes
|
||||
);
|
||||
enelixDemoUpdateBatterySoc($batterySoc, $previousBatteryPower, $minutes);
|
||||
} else {
|
||||
SetValue($bufferTemp, GetValue($bufferTemp));
|
||||
SetValue($batterySoc, GetValue($batterySoc));
|
||||
}
|
||||
|
||||
$powerBefore = enelixDemoControlledPower($contacts);
|
||||
$gridId = enelixDemoRequiredId($dashboard, 'Netzleistung');
|
||||
SetValue($gridId, round($base + $powerBefore - $pv, 1));
|
||||
|
||||
@@ -331,38 +549,60 @@ function enelixDemoTick(string $source = 'Manual'): void
|
||||
enelixDemoRequiredId($ems, 'Boiler1'),
|
||||
enelixDemoRequiredId($ems, 'Boiler2'),
|
||||
enelixDemoRequiredId($ems, 'Entfeuchter'),
|
||||
enelixDemoRequiredId($ems, 'Pufferspeicher'),
|
||||
enelixDemoRequiredId($ems, 'Batterie'),
|
||||
];
|
||||
foreach ($consumers as $consumerId) {
|
||||
IPS_RequestAction($consumerId, 'Melden', true);
|
||||
}
|
||||
$manager = enelixDemoRequiredId($ems, 'Manager');
|
||||
IPS_RequestAction($manager, 'Regeln', true);
|
||||
$managerReady = (int) IPS_GetInstance($manager)['InstanceStatus'] === 102;
|
||||
if ($managerReady) {
|
||||
foreach ($consumers as $consumerId) {
|
||||
IPS_RequestAction($consumerId, 'Melden', true);
|
||||
}
|
||||
IPS_RequestAction($manager, 'Regeln', true);
|
||||
$pv = enelixDemoLimitPvPower(
|
||||
$pvAvailable,
|
||||
(float) GetValue($inverterLimit)
|
||||
);
|
||||
SetValue($inverterActual, round($pv, 1));
|
||||
}
|
||||
|
||||
$battery = $managerReady
|
||||
? enelixDemoBatteryRegisterPower(
|
||||
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieManagement')),
|
||||
(int) GetValue(enelixDemoRequiredId($technical, 'BatterieModus')),
|
||||
(float) GetValue(enelixDemoRequiredId($technical, 'BatterieRegisterLeistung'))
|
||||
)
|
||||
: 0.0;
|
||||
SetValue($contacts['battery'], $battery);
|
||||
|
||||
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
||||
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
||||
$dryer = (bool) GetValue($contacts['dryer']) ? 900 : 0;
|
||||
$power = $boiler1 + $boiler2 + $dryer;
|
||||
$house = $base + $power;
|
||||
$grid = $house - $pv;
|
||||
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
|
||||
$loads = $boiler1 + $boiler2 + $dryer + $buffer;
|
||||
$emsPower = $loads + $battery;
|
||||
$house = $base + $loads;
|
||||
$grid = $house + $battery - $pv;
|
||||
|
||||
$pvId = enelixDemoRequiredId($dashboard, 'PVLeistung');
|
||||
$houseId = enelixDemoRequiredId($dashboard, 'Hausverbrauch');
|
||||
$loadId = enelixDemoRequiredId($dashboard, 'Verbraucherleistung');
|
||||
SetValue($gridId, round($grid, 1));
|
||||
SetValue($pvId, round($pv, 1));
|
||||
SetValue($houseId, round($house, 1));
|
||||
SetValue($loadId, (float) $power);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'PVLeistung'), round($pv, 1));
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'Hausverbrauch'), round($house, 1));
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'Verbraucherleistung'), (float) $emsPower);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'Boiler1Leistung'), (float) $boiler1);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'Boiler2Leistung'), (float) $boiler2);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'EntfeuchterLeistung'), (float) $dryer);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'PufferLeistung'), (float) $buffer);
|
||||
SetValue(enelixDemoRequiredId($dashboard, 'BatterieLeistung'), $battery);
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPVkW'), round($pv / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussNetzkW'), round($grid / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussGrundlastkW'), round($base / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler1kW'), round($boiler1 / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBoiler2kW'), round($boiler2 / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussEntfeuchterkW'), round($dryer / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussPufferkW'), round($buffer / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'EnergieflussBatteriekW'), round($battery / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'DiagrammHausverbrauchkW'), round($house / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($power / 1000.0, 3));
|
||||
SetValue(enelixDemoRequiredId($technical, 'DiagrammVerbraucherkW'), round($emsPower / 1000.0, 3));
|
||||
|
||||
$history = enelixDemoRequiredId($root, 'Historie');
|
||||
$energyIds = [
|
||||
@@ -378,7 +618,11 @@ function enelixDemoTick(string $source = 'Manual'): void
|
||||
enelixDemoAddEnergy($energyIds['import'], max(0.0, $grid), $hours);
|
||||
enelixDemoAddEnergy($energyIds['export'], max(0.0, -$grid), $hours);
|
||||
}
|
||||
}
|
||||
|
||||
function enelixDemoLimitPvPower(float $availablePower, float $limitPower): float
|
||||
{
|
||||
return min(max(0.0, $availablePower), max(0.0, $limitPower));
|
||||
}
|
||||
|
||||
function enelixDemoRenderFlow(array $state): string
|
||||
@@ -390,9 +634,15 @@ function enelixDemoRenderFlow(array $state): string
|
||||
$gridLabel = $grid < -25 ? 'Einspeisung' : ($grid > 25 ? 'Netzbezug' : 'Ausgeglichen');
|
||||
$gridArrow = $grid < -25 ? '→' : ($grid > 25 ? '←' : '↔');
|
||||
$gridClass = abs($grid) <= 250 ? 'good' : ($grid < 0 ? 'export' : 'import');
|
||||
$buffer = (float) ($state['buffer'] ?? 0.0);
|
||||
$battery = (float) ($state['battery'] ?? 0.0);
|
||||
$bufferTemp = (float) ($state['bufferTemp'] ?? 36.0);
|
||||
$batterySoc = (float) ($state['batterySoc'] ?? 50.0);
|
||||
$b1Class = (float) $state['boiler1'] > 0 ? 'on' : '';
|
||||
$b2Class = (float) $state['boiler2'] > 0 ? 'on' : '';
|
||||
$dryClass = (float) $state['dryer'] > 0 ? 'on' : '';
|
||||
$bufferClass = $buffer > 0 ? 'on' : '';
|
||||
$batteryClass = abs($battery) > 25 ? 'on' : '';
|
||||
|
||||
return '<style>
|
||||
.enelix-flow{box-sizing:border-box;font-family:Inter,Segoe UI,Arial,sans-serif;background:#10151c;color:#f4f7fa;padding:18px;min-height:420px}
|
||||
@@ -403,14 +653,14 @@ function enelixDemoRenderFlow(array $state): string
|
||||
.enelix-node small{display:block;color:#9dacbd;margin-bottom:8px}.enelix-node b{font-size:22px}
|
||||
.enelix-node.solar{border-color:#b99628}.enelix-node.bus{border-color:#2786ac}.enelix-node.good{border-color:#2bb673}
|
||||
.enelix-node.export{border-color:#38a3db}.enelix-node.import{border-color:#e95d5d}.enelix-arrow{text-align:center;font-size:30px;color:#9dacbd}
|
||||
.enelix-loads{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:18px}
|
||||
.enelix-loads{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-top:18px}
|
||||
.enelix-load{border-left:4px solid #566371;background:#17202a;padding:13px 14px;border-radius:6px}
|
||||
.enelix-load.on{border-left-color:#2bb673}.enelix-load b{display:block;font-size:17px;margin:4px 0}.enelix-load span{color:#9dacbd;font-size:12px}
|
||||
.enelix-foot{display:flex;justify-content:space-between;gap:12px;margin-top:16px;padding-top:12px;border-top:1px solid #2a3541;color:#9dacbd;font-size:12px}
|
||||
@media(max-width:640px){.enelix-line{grid-template-columns:1fr}.enelix-arrow{transform:rotate(90deg)}.enelix-loads{grid-template-columns:1fr}.enelix-head{align-items:start;flex-direction:column;gap:4px}}
|
||||
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt drei flexible Verbraucher</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
|
||||
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">→</div><div class="enelix-node bus"><small>Haus + flexible Lasten</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer']) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
|
||||
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div></div>
|
||||
</style><div class="enelix-flow"><div class="enelix-head"><div><div class="enelix-title">Enelix 2 Energiefluss</div><div class="enelix-sub">Manager regelt fünf flexible Teilnehmer aus vier Modultypen</div></div><div class="enelix-sub">Simulation ' . $time . ' Uhr</div></div>
|
||||
<div class="enelix-line"><div class="enelix-node solar"><small>PV-Erzeugung</small><b>' . $format((float) $state['pv']) . ' W</b></div><div class="enelix-arrow">→</div><div class="enelix-node bus"><small>Haus + EMS-Leistung</small><b>' . $format((float) $state['base'] + (float) $state['boiler1'] + (float) $state['boiler2'] + (float) $state['dryer'] + $buffer + $battery) . ' W</b></div><div class="enelix-arrow">' . $gridArrow . '</div><div class="enelix-node ' . $gridClass . '"><small>' . $gridLabel . '</small><b>' . $format(abs($grid)) . ' W</b></div></div>
|
||||
<div class="enelix-loads"><div class="enelix-load ' . $b1Class . '"><span>Priorität 1</span><b>Boiler 1 · ' . $format((float) $state['boiler1']) . ' W</b><span>' . number_format((float) $state['temp1'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $b2Class . '"><span>Priorität 2</span><b>Boiler 2 · ' . $format((float) $state['boiler2']) . ' W</b><span>' . number_format((float) $state['temp2'], 1, ',', '') . ' °C</span></div><div class="enelix-load ' . $dryClass . '"><span>Priorität 3</span><b>Entfeuchter · ' . $format((float) $state['dryer']) . ' W</b><span>einstufiger Verbraucher</span></div><div class="enelix-load ' . $bufferClass . '"><span>Priorität 4</span><b>Pufferspeicher · ' . $format($buffer) . ' W</b><span>' . number_format($bufferTemp, 1, ',', '') . ' °C · Heizkurve</span></div><div class="enelix-load ' . $batteryClass . '"><span>Priorität 5 / Peak 1</span><b>Batterie · ' . $format($battery) . ' W</b><span>' . number_format($batterySoc, 1, ',', '') . ' % · bidirektional</span></div></div>
|
||||
<div class="enelix-foot"><span>Grundlast ' . $format((float) $state['base']) . ' W</span><span>Manager: ' . htmlspecialchars((string) $state['manager'], ENT_QUOTES, 'UTF-8') . '</span></div></div>';
|
||||
}
|
||||
|
||||
@@ -418,7 +668,65 @@ function enelixDemoControlledPower(array $contacts): int
|
||||
{
|
||||
$boiler1 = (bool) GetValue($contacts['boiler12']) ? 2400 : ((bool) GetValue($contacts['boiler11']) ? 1200 : 0);
|
||||
$boiler2 = (bool) GetValue($contacts['boiler22']) ? 3000 : ((bool) GetValue($contacts['boiler21']) ? 1500 : 0);
|
||||
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0);
|
||||
$buffer = (bool) GetValue($contacts['buffer2']) ? 3600 : ((bool) GetValue($contacts['buffer1']) ? 1800 : 0);
|
||||
$battery = (int) round((float) GetValue($contacts['battery']));
|
||||
|
||||
return $boiler1 + $boiler2 + ((bool) GetValue($contacts['dryer']) ? 900 : 0) + $buffer + $battery;
|
||||
}
|
||||
|
||||
/** @return array{base: float, cloud: float, outside: float} */
|
||||
function enelixDemoEnvironment(float $hour, float $base, int $cloud, bool $dynamic): array
|
||||
{
|
||||
$hour = fmod(max(0.0, $hour), 24.0);
|
||||
$cloud = min(100, max(0, $cloud));
|
||||
if (!$dynamic) {
|
||||
return ['base' => $base, 'cloud' => (float) $cloud, 'outside' => 6.0];
|
||||
}
|
||||
|
||||
$morning = exp(-pow(($hour - 7.5) / 1.5, 2));
|
||||
$evening = exp(-pow(($hour - 19.0) / 2.2, 2));
|
||||
$night = ($hour < 5.0 || $hour >= 23.0) ? 0.65 : 0.82;
|
||||
$baseFactor = $night + (0.48 * $morning) + (0.72 * $evening);
|
||||
$cloudVariation = (18.0 * sin(($hour + 0.4) * 1.7)) + (8.0 * sin($hour * 4.6));
|
||||
$outside = 7.0 + (6.0 * sin(2.0 * M_PI * ($hour - 9.0) / 24.0));
|
||||
|
||||
return [
|
||||
'base' => max(150.0, $base * $baseFactor),
|
||||
'cloud' => min(100.0, max(0.0, $cloud + $cloudVariation)),
|
||||
'outside' => $outside,
|
||||
];
|
||||
}
|
||||
|
||||
function enelixDemoBatteryRegisterPower(int $management, int $mode, float $power): float
|
||||
{
|
||||
if ($management !== 2) {
|
||||
return 0.0;
|
||||
}
|
||||
if ($mode === 11) {
|
||||
return max(0.0, $power);
|
||||
}
|
||||
if ($mode === 12) {
|
||||
return -max(0.0, $power);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
function enelixDemoUpdateBufferTemperature(int $variableId, int $power, float $outside, int $minutes): void
|
||||
{
|
||||
$current = (float) GetValue($variableId);
|
||||
$heating = ($power / 1000.0) * 0.018 * $minutes;
|
||||
$loss = max(0.0, $current - $outside) * 0.0007 * $minutes;
|
||||
SetValue($variableId, round(min(65.0, max(22.0, $current + $heating - $loss)), 2));
|
||||
}
|
||||
|
||||
function enelixDemoUpdateBatterySoc(int $variableId, float $power, int $minutes): void
|
||||
{
|
||||
$current = (float) GetValue($variableId);
|
||||
$energy = ($power / 1000.0) * ($minutes / 60.0);
|
||||
$storedEnergy = $energy >= 0.0 ? $energy * 0.94 : $energy / 0.94;
|
||||
$delta = ($storedEnergy / 10.0) * 100.0;
|
||||
SetValue($variableId, round(min(100.0, max(5.0, $current + $delta)), 2));
|
||||
}
|
||||
|
||||
function enelixDemoUpdateTemperature(int $variableId, int $power, int $minutes): void
|
||||
@@ -439,6 +747,8 @@ function enelixDemoValidateRuntime(): void
|
||||
ENELIX_DEMO_MANAGER_MODULE,
|
||||
ENELIX_DEMO_BOILER_MODULE,
|
||||
ENELIX_DEMO_SINGLE_MODULE,
|
||||
ENELIX_DEMO_BUFFER_MODULE,
|
||||
ENELIX_DEMO_BATTERY_MODULE,
|
||||
ENELIX_DEMO_DUMMY_MODULE,
|
||||
ENELIX_DEMO_ENERGY_DISTRIBUTION_MODULE,
|
||||
] as $moduleId) {
|
||||
@@ -450,10 +760,11 @@ function enelixDemoValidateRuntime(): void
|
||||
|
||||
function enelixDemoEnsureProfiles(): void
|
||||
{
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -12000, 12000, 100, 0, ' W');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 12000, 100, 0, ' W');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Power', 2, -20000, 20000, 100, 0, ' W');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.PowerPositive', 2, 0, 20000, 100, 0, ' W');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Hour', 2, 0, 23.75, 0.25, 2, ' h');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Percent', 1, 0, 100, 5, 0, ' %');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.PercentFloat', 2, 0, 100, 0.1, 1, ' %');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Speed', 1, 1, 60, 1, 0, ' min');
|
||||
enelixDemoEnsureProfile('ENELIX.Demo.Energy', 2, 0, 1000000, 0.01, 2, ' kWh');
|
||||
}
|
||||
@@ -628,15 +939,34 @@ function enelixDemoEnsureCyclicEvent(int $scriptId, string $ident, int $seconds)
|
||||
return $id;
|
||||
}
|
||||
|
||||
function enelixDemoConfigure(int $instanceId, array $properties): void
|
||||
function enelixDemoConfigure(
|
||||
int $instanceId,
|
||||
array $properties,
|
||||
array $allowedStatuses = [102]
|
||||
): void
|
||||
{
|
||||
foreach ($properties as $name => $value) {
|
||||
IPS_SetProperty($instanceId, (string) $name, $value);
|
||||
}
|
||||
IPS_ApplyChanges($instanceId);
|
||||
if ((int) IPS_GetInstance($instanceId)['InstanceStatus'] !== 102) {
|
||||
throw new RuntimeException('Instanz ist nach der Konfiguration nicht aktiv: ' . IPS_GetName($instanceId));
|
||||
$deadline = microtime(true) + 2.0;
|
||||
do {
|
||||
$status = (int) IPS_GetInstance($instanceId)['InstanceStatus'];
|
||||
if ($status === 102) {
|
||||
return;
|
||||
}
|
||||
IPS_Sleep(50);
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
if (in_array($status, $allowedStatuses, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'Instanz ist nach der Konfiguration nicht aktiv: '
|
||||
. IPS_GetName($instanceId)
|
||||
. ' (Status ' . $status . ', erwartet: ' . implode(', ', $allowedStatuses) . ')'
|
||||
);
|
||||
}
|
||||
|
||||
function enelixDemoConfigureArchive(array $variableIds): void
|
||||
@@ -713,7 +1043,7 @@ function enelixDemoRemove(bool $confirmed = false): void
|
||||
IPS_ApplyChanges($visualization);
|
||||
}
|
||||
enelixDemoDeleteTree($root);
|
||||
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
|
||||
foreach (['ENELIX.Demo.Power', 'ENELIX.Demo.PowerPositive', 'ENELIX.Demo.Hour', 'ENELIX.Demo.Percent', 'ENELIX.Demo.PercentFloat', 'ENELIX.Demo.Speed', 'ENELIX.Demo.Energy'] as $profile) {
|
||||
if (IPS_VariableProfileExists($profile)) {
|
||||
IPS_DeleteVariableProfile($profile);
|
||||
}
|
||||
@@ -771,9 +1101,14 @@ function enelixDemoConfigureEnergyDistribution(int $instanceId, array $nodes): v
|
||||
]);
|
||||
}
|
||||
|
||||
function enelixDemoLinkInstanceVariables(int $categoryId, int $instanceId, int $sensorId): void
|
||||
{
|
||||
enelixDemoEnsureLink($categoryId, 'SensorTemperatur', 'Gemessene Temperatur', $sensorId, 5);
|
||||
function enelixDemoLinkInstanceVariables(
|
||||
int $categoryId,
|
||||
int $instanceId,
|
||||
int $sensorId,
|
||||
string $sensorName = 'Gemessene Temperatur',
|
||||
string $sensorIdent = 'SensorTemperatur'
|
||||
): void {
|
||||
enelixDemoEnsureLink($categoryId, $sensorIdent, $sensorName, $sensorId, 5);
|
||||
$position = 10;
|
||||
foreach (IPS_GetChildrenIDs($instanceId) as $childId) {
|
||||
if ((int) IPS_GetObject($childId)['ObjectType'] !== 2) {
|
||||
|
||||
+2
-2
@@ -7,6 +7,6 @@
|
||||
"version": "8.0"
|
||||
},
|
||||
"version": "0.1",
|
||||
"build": 0,
|
||||
"date": 0
|
||||
"build": 1,
|
||||
"date": 1790662541
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
|
||||
final class Anlagentopologie
|
||||
{
|
||||
public const VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* @throws JsonException
|
||||
*/
|
||||
public static function ausJson(
|
||||
string $wechselrichterJson,
|
||||
string $pvFlaechenJson,
|
||||
string $batterienJson,
|
||||
string $installationID
|
||||
): array {
|
||||
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
|
||||
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
|
||||
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
|
||||
|
||||
$inverters = [];
|
||||
$inverterById = [];
|
||||
foreach ($wechselrichter as $index => $eintrag) {
|
||||
$pfad = 'Wechselrichter ' . ($index + 1);
|
||||
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
|
||||
if (isset($inverterById[$id])) {
|
||||
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||
}
|
||||
$typ = self::auswahl(
|
||||
$eintrag['Typ'] ?? null,
|
||||
['pv', 'battery', 'hybrid'],
|
||||
$pfad . ': Typ'
|
||||
);
|
||||
$acRatedKw = self::positiveZahl(
|
||||
$eintrag['ACNennleistung_kW'] ?? null,
|
||||
$pfad . ': AC-Nennleistung'
|
||||
);
|
||||
$inverter = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'type' => $typ,
|
||||
'acRatedKw' => $acRatedKw,
|
||||
];
|
||||
$inverters[] = $inverter;
|
||||
$inverterById[$id] = $inverter;
|
||||
}
|
||||
|
||||
$pvArrays = [];
|
||||
$pvIds = [];
|
||||
foreach ($pvFlaechen as $index => $eintrag) {
|
||||
$pfad = 'PV-Flaeche ' . ($index + 1);
|
||||
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
|
||||
$inverterId = self::kennung(
|
||||
$eintrag['WechselrichterID'] ?? null,
|
||||
$pfad . ': Wechselrichter-ID'
|
||||
);
|
||||
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
$moduleCount = self::optionalePositiveGanzzahl(
|
||||
$eintrag['Modulanzahl'] ?? null,
|
||||
$pfad . ': Modulanzahl'
|
||||
);
|
||||
$modulePowerWp = self::optionalePositiveZahl(
|
||||
$eintrag['Modulleistung_Wp'] ?? null,
|
||||
$pfad . ': Modulleistung'
|
||||
);
|
||||
if (($moduleCount === null) !== ($modulePowerWp === null)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
|
||||
);
|
||||
}
|
||||
$pvArrays[] = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'dcKwp' => self::positiveZahl(
|
||||
$eintrag['DCLeistung_kWp'] ?? null,
|
||||
$pfad . ': DC-Leistung'
|
||||
),
|
||||
'tiltDeg' => self::zahlImBereich(
|
||||
$eintrag['Neigung_Grad'] ?? null,
|
||||
0.0,
|
||||
90.0,
|
||||
$pfad . ': Neigung'
|
||||
),
|
||||
'azimuthDeg' => self::zahlImBereich(
|
||||
$eintrag['Azimut_Grad'] ?? null,
|
||||
-180.0,
|
||||
180.0,
|
||||
$pfad . ': Azimut'
|
||||
),
|
||||
'inverterId' => $inverterId,
|
||||
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
|
||||
'moduleCount' => $moduleCount,
|
||||
'modulePowerWp' => $modulePowerWp,
|
||||
];
|
||||
}
|
||||
|
||||
$batteryItems = [];
|
||||
$batteryIds = [];
|
||||
foreach ($batterien as $index => $eintrag) {
|
||||
$pfad = 'Batterie ' . ($index + 1);
|
||||
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
|
||||
$inverterId = self::kennung(
|
||||
$eintrag['WechselrichterID'] ?? null,
|
||||
$pfad . ': Wechselrichter-ID'
|
||||
);
|
||||
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
|
||||
$kopplung = self::auswahl(
|
||||
$eintrag['Kopplung'] ?? null,
|
||||
['ac', 'dc', 'hybrid'],
|
||||
$pfad . ': Kopplung'
|
||||
);
|
||||
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
|
||||
);
|
||||
}
|
||||
$nominalCapacityKwh = self::positiveZahl(
|
||||
$eintrag['Nennkapazitaet_kWh'] ?? null,
|
||||
$pfad . ': Nennkapazitaet'
|
||||
);
|
||||
$usableCapacityKwh = self::positiveZahl(
|
||||
$eintrag['Nutzkapazitaet_kWh'] ?? null,
|
||||
$pfad . ': Nutzkapazitaet'
|
||||
);
|
||||
if ($usableCapacityKwh > $nominalCapacityKwh) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
|
||||
);
|
||||
}
|
||||
$batteryItems[] = [
|
||||
'id' => $id,
|
||||
'name' => self::name($eintrag['Name'] ?? null, $id),
|
||||
'nominalCapacityKwh' => $nominalCapacityKwh,
|
||||
'usableCapacityKwh' => $usableCapacityKwh,
|
||||
'maxChargeKw' => self::positiveZahl(
|
||||
$eintrag['MaxLadeleistung_kW'] ?? null,
|
||||
$pfad . ': maximale Ladeleistung'
|
||||
),
|
||||
'maxDischargeKw' => self::positiveZahl(
|
||||
$eintrag['MaxEntladeleistung_kW'] ?? null,
|
||||
$pfad . ': maximale Entladeleistung'
|
||||
),
|
||||
'inverterId' => $inverterId,
|
||||
'coupling' => $kopplung,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => self::VERSION,
|
||||
'installationId' => trim($installationID),
|
||||
'inverters' => $inverters,
|
||||
'pvArrays' => $pvArrays,
|
||||
'batteries' => $batteryItems,
|
||||
'sharedAcLimits' => self::gemeinsameAcGrenzen(
|
||||
$inverters,
|
||||
$pvArrays,
|
||||
$batteryItems
|
||||
),
|
||||
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
private static function dekodiereListe(string $json, string $name): array
|
||||
{
|
||||
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($daten)
|
||||
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
|
||||
) {
|
||||
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
|
||||
}
|
||||
foreach ($daten as $eintrag) {
|
||||
if (!is_array($eintrag)) {
|
||||
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
|
||||
}
|
||||
}
|
||||
|
||||
return $daten;
|
||||
}
|
||||
|
||||
/** @param array<string, bool> $verwendet */
|
||||
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
|
||||
{
|
||||
$id = self::kennung($wert, $pfad . ': ID');
|
||||
if (isset($verwendet[$id])) {
|
||||
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
|
||||
}
|
||||
$verwendet[$id] = true;
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private static function kennung($wert, string $feld): string
|
||||
{
|
||||
if (!is_string($wert)
|
||||
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
|
||||
);
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
private static function name($wert, string $fallback): string
|
||||
{
|
||||
if ($wert === null || $wert === '') {
|
||||
return $fallback;
|
||||
}
|
||||
if (!is_string($wert) || strlen(trim($wert)) > 120) {
|
||||
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
/** @param list<string> $werte */
|
||||
private static function auswahl($wert, array $werte, string $feld): string
|
||||
{
|
||||
if (!is_string($wert) || !in_array($wert, $werte, true)) {
|
||||
throw new InvalidArgumentException($feld . ' ist ungueltig.');
|
||||
}
|
||||
|
||||
return $wert;
|
||||
}
|
||||
|
||||
private static function positiveZahl($wert, string $feld): float
|
||||
{
|
||||
if (!is_int($wert) && !is_float($wert)) {
|
||||
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
|
||||
}
|
||||
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
|
||||
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
return (float) $wert;
|
||||
}
|
||||
|
||||
private static function optionalePositiveZahl($wert, string $feld): ?float
|
||||
{
|
||||
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::positiveZahl($wert, $feld);
|
||||
}
|
||||
|
||||
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
|
||||
{
|
||||
if ($wert === null || $wert === '' || $wert === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!is_int($wert) || $wert <= 0) {
|
||||
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
|
||||
}
|
||||
|
||||
return $wert;
|
||||
}
|
||||
|
||||
private static function zahlImBereich(
|
||||
$wert,
|
||||
float $minimum,
|
||||
float $maximum,
|
||||
string $feld
|
||||
): float {
|
||||
if ((!is_int($wert) && !is_float($wert))
|
||||
|| !is_finite((float) $wert)
|
||||
|| (float) $wert < $minimum
|
||||
|| (float) $wert > $maximum
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $wert;
|
||||
}
|
||||
|
||||
private static function optionalerText($wert): ?string
|
||||
{
|
||||
if ($wert === null || $wert === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_string($wert) || strlen(trim($wert)) > 64) {
|
||||
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
|
||||
}
|
||||
|
||||
return trim($wert);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $wechselrichter
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
|
||||
{
|
||||
if (!isset($wechselrichter[$id])) {
|
||||
throw new InvalidArgumentException(
|
||||
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
|
||||
);
|
||||
}
|
||||
|
||||
return $wechselrichter[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $inverters
|
||||
* @param list<array<string, mixed>> $pvArrays
|
||||
* @param list<array<string, mixed>> $batteries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function gemeinsameAcGrenzen(
|
||||
array $inverters,
|
||||
array $pvArrays,
|
||||
array $batteries
|
||||
): array {
|
||||
$result = [];
|
||||
foreach ($inverters as $inverter) {
|
||||
if ($inverter['type'] !== 'hybrid') {
|
||||
continue;
|
||||
}
|
||||
$pvIds = [];
|
||||
foreach ($pvArrays as $pvArray) {
|
||||
if ($pvArray['inverterId'] === $inverter['id']) {
|
||||
$pvIds[] = $pvArray['id'];
|
||||
}
|
||||
}
|
||||
$batteryIds = [];
|
||||
foreach ($batteries as $battery) {
|
||||
if ($battery['inverterId'] === $inverter['id']) {
|
||||
$batteryIds[] = $battery['id'];
|
||||
}
|
||||
}
|
||||
if ($pvIds === [] && $batteryIds === []) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'inverterId' => $inverter['id'],
|
||||
'acRatedKw' => $inverter['acRatedKw'],
|
||||
'pvArrayIds' => $pvIds,
|
||||
'batteryIds' => $batteryIds,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $inverters
|
||||
* @param list<array<string, mixed>> $pvArrays
|
||||
* @param list<array<string, mixed>> $batteries
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private static function zusammenfassung(
|
||||
array $inverters,
|
||||
array $pvArrays,
|
||||
array $batteries
|
||||
): array {
|
||||
$pvInverterIds = [];
|
||||
$pvDcKwp = 0.0;
|
||||
foreach ($pvArrays as $pvArray) {
|
||||
$pvDcKwp += $pvArray['dcKwp'];
|
||||
$pvInverterIds[$pvArray['inverterId']] = true;
|
||||
}
|
||||
$pvAcKw = 0.0;
|
||||
foreach ($inverters as $inverter) {
|
||||
if (isset($pvInverterIds[$inverter['id']])) {
|
||||
$pvAcKw += $inverter['acRatedKw'];
|
||||
}
|
||||
}
|
||||
$usableBatteryKwh = 0.0;
|
||||
$maxBatteryChargeKw = 0.0;
|
||||
$maxBatteryDischargeKw = 0.0;
|
||||
foreach ($batteries as $battery) {
|
||||
$usableBatteryKwh += $battery['usableCapacityKwh'];
|
||||
$maxBatteryChargeKw += $battery['maxChargeKw'];
|
||||
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
|
||||
}
|
||||
|
||||
return [
|
||||
'pvDcKwp' => $pvDcKwp,
|
||||
'pvAcKw' => $pvAcKw,
|
||||
'usableBatteryKwh' => $usableBatteryKwh,
|
||||
'maxBatteryChargeKw' => $maxBatteryChargeKw,
|
||||
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
|
||||
];
|
||||
}
|
||||
}
|
||||
+29
-49
@@ -17,38 +17,6 @@ final class BatterieRegler
|
||||
public const STEUERUNG_WECHSELRICHTER = 1;
|
||||
public const STEUERUNG_ENELIX = 2;
|
||||
|
||||
/**
|
||||
* Erzeugt das bisherige Raster: 250-W-Grundschritte und
|
||||
* 50-W-Feinschritte im Bereich von -200 W bis +200 W.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function leistungsraster(float $maxLadenW, float $maxEntladenW): array
|
||||
{
|
||||
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
|
||||
|
||||
$maxLaden = (int) floor($maxLadenW / 50.0) * 50;
|
||||
$maxEntladen = (int) floor($maxEntladenW / 50.0) * 50;
|
||||
$werte = [0];
|
||||
|
||||
for ($leistung = -$maxEntladen; $leistung <= 0; $leistung += 250) {
|
||||
$werte[] = $leistung;
|
||||
}
|
||||
for ($leistung = 0; $leistung <= $maxLaden; $leistung += 250) {
|
||||
$werte[] = $leistung;
|
||||
}
|
||||
for ($leistung = -200; $leistung <= 200; $leistung += 50) {
|
||||
if ($leistung >= -$maxEntladen && $leistung <= $maxLaden) {
|
||||
$werte[] = $leistung;
|
||||
}
|
||||
}
|
||||
|
||||
$werte = array_values(array_unique($werte));
|
||||
sort($werte, SORT_NUMERIC);
|
||||
|
||||
return $werte;
|
||||
}
|
||||
|
||||
public static function hystereseAktiv(
|
||||
float $ladezustand,
|
||||
float $reserve,
|
||||
@@ -68,9 +36,10 @@ final class BatterieRegler
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet das zustands- und betriebsartabhaengige Angebot aus Enelix 1 ab.
|
||||
* Bildet das zustands- und betriebsartabhaengige Angebot als
|
||||
* durchgaengige Leistungsbereiche in ganzen Watt ab.
|
||||
*
|
||||
* @return list<int>
|
||||
* @return list<int|array{Von_W: int, Bis_W: int}>
|
||||
*/
|
||||
public static function leistungsangebot(
|
||||
bool $aktiv,
|
||||
@@ -83,7 +52,8 @@ final class BatterieRegler
|
||||
float $netzleistungW,
|
||||
float $istleistungW,
|
||||
float $maxLadenW,
|
||||
float $maxEntladenW
|
||||
float $maxEntladenW,
|
||||
?float $maxNachladenW = null
|
||||
): array {
|
||||
self::pruefeLadezustandsgrenzen($reserve, 0.0);
|
||||
if ($minimum < 0.0 || $minimum > 100.0 || $minimum > $reserve) {
|
||||
@@ -92,12 +62,17 @@ final class BatterieRegler
|
||||
);
|
||||
}
|
||||
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
|
||||
if ($maxNachladenW !== null && $maxNachladenW < 0.0) {
|
||||
throw new InvalidArgumentException('Maximale Nachladeleistung darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
if (!$aktiv || !$enelixSteuert) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
$raster = self::leistungsraster($maxLadenW, $maxEntladenW);
|
||||
$maxLaden = (int) floor($maxLadenW);
|
||||
$maxNachladen = (int) floor(min($maxLadenW, $maxNachladenW ?? $maxLadenW));
|
||||
$maxEntladen = (int) floor($maxEntladenW);
|
||||
if ($peakbetrieb) {
|
||||
if ($ladezustand > $reserve && !$hystereseAktiv) {
|
||||
$ziel = $netzleistungW > 0.0
|
||||
@@ -107,26 +82,26 @@ final class BatterieRegler
|
||||
return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)];
|
||||
}
|
||||
if ($ladezustand > $reserve && $hystereseAktiv) {
|
||||
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0);
|
||||
return self::leistungsbereich(-$maxEntladen, 0);
|
||||
}
|
||||
if ($ladezustand > $minimum) {
|
||||
return $raster;
|
||||
return self::leistungsbereich(-$maxEntladen, $maxNachladen);
|
||||
}
|
||||
|
||||
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0);
|
||||
return self::leistungsbereich(0, $maxNachladen);
|
||||
}
|
||||
|
||||
if ($ladezustand > 99.0) {
|
||||
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0);
|
||||
return self::leistungsbereich(-$maxEntladen, 0);
|
||||
}
|
||||
if ($ladezustand > $reserve && !$hystereseAktiv) {
|
||||
return $raster;
|
||||
return self::leistungsbereich(-$maxEntladen, $maxLaden);
|
||||
}
|
||||
if ($ladezustand >= $reserve && $hystereseAktiv) {
|
||||
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0);
|
||||
return self::leistungsbereich(0, $maxNachladen);
|
||||
}
|
||||
|
||||
return [(int) round($maxLadenW)];
|
||||
return [$maxNachladen];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,12 +195,14 @@ final class BatterieRegler
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** @param list<int> $werte
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function filtere(array $werte, callable $filter): array
|
||||
/** @return list<int|array{Von_W: int, Bis_W: int}> */
|
||||
private static function leistungsbereich(int $vonW, int $bisW): array
|
||||
{
|
||||
return array_values(array_filter($werte, $filter));
|
||||
if ($vonW === $bisW) {
|
||||
return [$vonW];
|
||||
}
|
||||
|
||||
return [['Von_W' => $vonW, 'Bis_W' => $bisW]];
|
||||
}
|
||||
|
||||
private static function begrenzeLeistung(
|
||||
@@ -233,7 +210,10 @@ final class BatterieRegler
|
||||
float $maxLadenW,
|
||||
float $maxEntladenW
|
||||
): int {
|
||||
return (int) round(min($maxLadenW, max(-$maxEntladenW, $leistungW)));
|
||||
$maxLaden = (int) floor($maxLadenW);
|
||||
$maxEntladen = (int) floor($maxEntladenW);
|
||||
|
||||
return (int) round(min($maxLaden, max(-$maxEntladen, $leistungW)));
|
||||
}
|
||||
|
||||
private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void
|
||||
|
||||
@@ -8,7 +8,8 @@ final class EaseeGatewayProtokoll
|
||||
{
|
||||
/** @var list<int> */
|
||||
public const BEOBACHTUNGEN = [
|
||||
47, 48, 100, 104, 109, 110, 119, 120, 182, 183, 184, 185, 250,
|
||||
31, 41, 42, 43, 47, 48, 62, 96, 100, 102, 104, 109, 110,
|
||||
111, 112, 113, 114, 119, 120, 182, 183, 184, 185, 250,
|
||||
];
|
||||
|
||||
public static function seriennummer(string $wert): string
|
||||
|
||||
@@ -22,6 +22,11 @@ final class EaseeLadestatus
|
||||
* Istleistung_W: float,
|
||||
* Ladestrom_A: float,
|
||||
* ApiMaximalstrom_A: int,
|
||||
* EaseeAktiviert: bool,
|
||||
* AutorisierungErforderlich: bool,
|
||||
* RemoteStartErforderlich: bool,
|
||||
* SmartCharging: bool,
|
||||
* GrundKeinLadestrom: int,
|
||||
* Stoerung: bool,
|
||||
* Stoertext: string
|
||||
* }
|
||||
@@ -48,11 +53,16 @@ final class EaseeLadestatus
|
||||
? self::phasenzahl((int) ($beobachtungen['110'] ?? 0))
|
||||
: 0;
|
||||
|
||||
$leistung = max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0);
|
||||
$laedt = $betriebszustand === 3;
|
||||
$leistung = $laedt
|
||||
? max(0.0, (float) ($beobachtungen['120'] ?? 0.0) * 1000.0)
|
||||
: 0.0;
|
||||
$stroeme = [];
|
||||
foreach ([182, 183, 184, 185] as $id) {
|
||||
if (array_key_exists((string) $id, $beobachtungen)) {
|
||||
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
|
||||
if ($laedt) {
|
||||
foreach ([182, 183, 184, 185] as $id) {
|
||||
if (array_key_exists((string) $id, $beobachtungen)) {
|
||||
$stroeme[] = abs((float) $beobachtungen[(string) $id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$ladestrom = $stroeme === [] ? 0.0 : max($stroeme);
|
||||
@@ -65,6 +75,16 @@ final class EaseeLadestatus
|
||||
}
|
||||
}
|
||||
$apiMaximalstrom = max(6, min($grenzen));
|
||||
$easeeAktiviert = !array_key_exists('31', $beobachtungen)
|
||||
|| (bool) $beobachtungen['31'];
|
||||
$autorisierungErforderlich = (bool) (
|
||||
$beobachtungen['42'] ?? $beobachtungen['41'] ?? false
|
||||
);
|
||||
$remoteStartErforderlich = (bool) ($beobachtungen['43'] ?? false);
|
||||
$smartCharging = (bool) ($beobachtungen['102'] ?? false);
|
||||
$grundKeinLadestrom = is_numeric($beobachtungen['96'] ?? null)
|
||||
? (int) $beobachtungen['96']
|
||||
: 0;
|
||||
|
||||
$fehlercode = (int) ($beobachtungen['119'] ?? 0);
|
||||
$stoerung = $betriebszustand === 5 || $fehlercode !== 0;
|
||||
@@ -85,6 +105,11 @@ final class EaseeLadestatus
|
||||
'Istleistung_W' => $leistung,
|
||||
'Ladestrom_A' => $ladestrom,
|
||||
'ApiMaximalstrom_A' => $apiMaximalstrom,
|
||||
'EaseeAktiviert' => $easeeAktiviert,
|
||||
'AutorisierungErforderlich' => $autorisierungErforderlich,
|
||||
'RemoteStartErforderlich' => $remoteStartErforderlich,
|
||||
'SmartCharging' => $smartCharging,
|
||||
'GrundKeinLadestrom' => $grundKeinLadestrom,
|
||||
'Stoerung' => $stoerung,
|
||||
'Stoertext' => trim($stoertext),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Verteilt eine gemeinsame Einspeisegrenze auf regelbare PV-Wechselrichter.
|
||||
*
|
||||
* Netzleistung: positiv Bezug, negativ Einspeisung.
|
||||
*/
|
||||
final class EinspeiseRegler
|
||||
{
|
||||
/**
|
||||
* @param list<array{ID: string, Nennleistung_W: float|int, Istleistung_W: float|int}> $wechselrichter
|
||||
* @param array<string, float|int> $letzteGrenzen
|
||||
* @return array{
|
||||
* Netzabweichung_W: float,
|
||||
* ZielGesamtleistung_W: float,
|
||||
* Abregelbedarf_W: float,
|
||||
* Grenzen_W: array<string, float>
|
||||
* }
|
||||
*/
|
||||
public static function verteile(
|
||||
float $netzleistung,
|
||||
float $einspeisegrenze,
|
||||
array $wechselrichter,
|
||||
array $letzteGrenzen = []
|
||||
): array {
|
||||
if ($einspeisegrenze < 0.0) {
|
||||
throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.');
|
||||
}
|
||||
if ($wechselrichter === []) {
|
||||
throw new InvalidArgumentException('Mindestens ein regelbarer Wechselrichter ist erforderlich.');
|
||||
}
|
||||
|
||||
$nennleistungGesamt = 0.0;
|
||||
$istleistungGesamt = 0.0;
|
||||
$bisherigeGrenzeGesamt = 0.0;
|
||||
$normalisiert = [];
|
||||
$kennungen = [];
|
||||
|
||||
foreach ($wechselrichter as $eintrag) {
|
||||
$id = $eintrag['ID'] ?? null;
|
||||
$nennleistung = $eintrag['Nennleistung_W'] ?? null;
|
||||
$istleistung = $eintrag['Istleistung_W'] ?? null;
|
||||
if (!is_string($id) || trim($id) === '' || isset($kennungen[$id])) {
|
||||
throw new InvalidArgumentException('Wechselrichter-ID fehlt oder ist mehrfach vorhanden.');
|
||||
}
|
||||
if (!is_numeric($nennleistung) || (float) $nennleistung <= 0.0) {
|
||||
throw new InvalidArgumentException('Wechselrichter-Nennleistung muss groesser als 0 sein.');
|
||||
}
|
||||
if (!is_numeric($istleistung) || (float) $istleistung < 0.0) {
|
||||
throw new InvalidArgumentException('Wechselrichter-Istleistung darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
$nennleistung = (float) $nennleistung;
|
||||
$istleistung = min((float) $istleistung, $nennleistung);
|
||||
$bisherigeGrenze = $letzteGrenzen[$id] ?? $nennleistung;
|
||||
if (!is_numeric($bisherigeGrenze)) {
|
||||
throw new InvalidArgumentException('Gespeicherte Wechselrichtergrenze ist ungueltig.');
|
||||
}
|
||||
$bisherigeGrenze = max(0.0, min((float) $bisherigeGrenze, $nennleistung));
|
||||
$normalisiert[] = [
|
||||
'ID' => $id,
|
||||
'Nennleistung_W' => $nennleistung,
|
||||
'Istleistung_W' => $istleistung,
|
||||
];
|
||||
$kennungen[$id] = true;
|
||||
$nennleistungGesamt += $nennleistung;
|
||||
$istleistungGesamt += $istleistung;
|
||||
$bisherigeGrenzeGesamt += $bisherigeGrenze;
|
||||
}
|
||||
|
||||
$netzabweichung = $netzleistung + $einspeisegrenze;
|
||||
$zielGesamt = $bisherigeGrenzeGesamt;
|
||||
$nachIstleistungVerteilen = false;
|
||||
if ($netzabweichung < 0.0) {
|
||||
$nachIstleistungVerteilen = $istleistungGesamt > 0.0;
|
||||
$zielGesamt = min($bisherigeGrenzeGesamt, $istleistungGesamt) + $netzabweichung;
|
||||
} elseif ($netzabweichung > 0.0) {
|
||||
$zielGesamt = $bisherigeGrenzeGesamt + $netzabweichung;
|
||||
}
|
||||
$zielGesamt = max(0.0, min($zielGesamt, $nennleistungGesamt));
|
||||
|
||||
$grenzen = [];
|
||||
foreach ($normalisiert as $eintrag) {
|
||||
$anteil = $nachIstleistungVerteilen
|
||||
? $eintrag['Istleistung_W'] / $istleistungGesamt
|
||||
: $eintrag['Nennleistung_W'] / $nennleistungGesamt;
|
||||
$grenzen[$eintrag['ID']] = round(
|
||||
min($eintrag['Nennleistung_W'], $zielGesamt * $anteil),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'Netzabweichung_W' => $netzabweichung,
|
||||
'ZielGesamtleistung_W' => round($zielGesamt, 3),
|
||||
'Abregelbedarf_W' => round(max(0.0, $istleistungGesamt - $zielGesamt), 3),
|
||||
'Grenzen_W' => $grenzen,
|
||||
];
|
||||
}
|
||||
|
||||
public static function stellwert(float $grenzeW, float $nennleistungW, string $art): float
|
||||
{
|
||||
if ($nennleistungW <= 0.0 || $grenzeW < 0.0) {
|
||||
throw new InvalidArgumentException('Leistungsgrenzen sind ungueltig.');
|
||||
}
|
||||
$grenzeW = min($grenzeW, $nennleistungW);
|
||||
if ($art === 'percent') {
|
||||
return round($grenzeW / $nennleistungW * 100.0, 2);
|
||||
}
|
||||
if ($art === 'watt') {
|
||||
return round($grenzeW, 1);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Begrenzungsart muss percent oder watt sein.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
final class EnergieMessung
|
||||
{
|
||||
private const WATTSEKUNDEN_PRO_KWH = 3600000.0;
|
||||
|
||||
public static function hausleistung(float $pv, float $netz, float $batterie, float $sdl = 0.0): float
|
||||
{
|
||||
return max(
|
||||
0.0,
|
||||
self::endlich($pv) + self::endlich($netz) - self::endlich($batterie) - self::endlich($sdl)
|
||||
);
|
||||
}
|
||||
|
||||
public static function hausenergie(
|
||||
float $pv,
|
||||
float $netzbezug,
|
||||
float $netzeinspeisung,
|
||||
float $batterieLaden,
|
||||
float $batterieEntladen,
|
||||
float $sdlLaden = 0.0,
|
||||
float $sdlEntladen = 0.0
|
||||
): float {
|
||||
return max(
|
||||
0.0,
|
||||
self::endlich($pv)
|
||||
+ self::endlich($netzbezug)
|
||||
+ self::endlich($batterieEntladen)
|
||||
- self::endlich($netzeinspeisung)
|
||||
- self::endlich($batterieLaden)
|
||||
- self::endlich($sdlLaden)
|
||||
+ self::endlich($sdlEntladen)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{PV: float, Haus: float, Netz: float, Batterie: float, SDL?: float} $leistungen
|
||||
* @return array{
|
||||
* PVEnergie: float,
|
||||
* Hausenergie: float,
|
||||
* NetzbezugEnergie: float,
|
||||
* EinspeisungEnergie: float,
|
||||
* BatterieLadenEnergie: float,
|
||||
* BatterieEntladenEnergie: float,
|
||||
* SDLLadenEnergie: float,
|
||||
* SDLEntladenEnergie: float
|
||||
* }
|
||||
*/
|
||||
public static function energieDeltas(array $leistungen, int $sekunden): array
|
||||
{
|
||||
$faktor = max(0, $sekunden) / self::WATTSEKUNDEN_PRO_KWH;
|
||||
$pv = self::endlich($leistungen['PV']);
|
||||
$haus = self::endlich($leistungen['Haus']);
|
||||
$netz = self::endlich($leistungen['Netz']);
|
||||
$batterie = self::endlich($leistungen['Batterie']);
|
||||
$sdl = self::endlich($leistungen['SDL'] ?? 0.0);
|
||||
|
||||
return [
|
||||
'PVEnergie' => max(0.0, $pv) * $faktor,
|
||||
'Hausenergie' => max(0.0, $haus) * $faktor,
|
||||
'NetzbezugEnergie' => max(0.0, $netz) * $faktor,
|
||||
'EinspeisungEnergie' => max(0.0, -$netz) * $faktor,
|
||||
'BatterieLadenEnergie' => max(0.0, $batterie) * $faktor,
|
||||
'BatterieEntladenEnergie' => max(0.0, -$batterie) * $faktor,
|
||||
'SDLLadenEnergie' => max(0.0, $sdl) * $faktor,
|
||||
'SDLEntladenEnergie' => max(0.0, -$sdl) * $faktor,
|
||||
];
|
||||
}
|
||||
|
||||
private static function endlich(float $wert): float
|
||||
{
|
||||
return is_finite($wert) ? $wert : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ final class LadestationAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{Rohstatus: int, Istleistung_W: float}
|
||||
* @return array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int}
|
||||
*/
|
||||
public static function dekodiereStatus(int $geraetetyp, string $antwort): array
|
||||
{
|
||||
@@ -110,12 +110,47 @@ final class LadestationAdapter
|
||||
throw new RuntimeException('Die go-e-Antwort enthaelt nicht alle Statusfelder.');
|
||||
}
|
||||
|
||||
$faktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
|
||||
|
||||
return [
|
||||
$leistungsfaktor = $geraetetyp === self::GO_E_ALT ? 10.0 : 1.0;
|
||||
$status = [
|
||||
'Rohstatus' => (int) $daten['car'],
|
||||
'Istleistung_W' => (float) $daten['nrg'][11] * $faktor,
|
||||
'Istleistung_W' => (float) $daten['nrg'][11] * $leistungsfaktor,
|
||||
];
|
||||
if (isset($daten['amp']) && is_numeric($daten['amp'])) {
|
||||
$sollstrom = (int) $daten['amp'];
|
||||
$freigabeEntzogen = isset($daten['alw']) && $daten['alw'] === false;
|
||||
$erzwungenAus = $geraetetyp === self::GO_E_GEMINI
|
||||
&& isset($daten['frc'])
|
||||
&& (int) $daten['frc'] === 1;
|
||||
$status['Sollstrom_A'] = $freigabeEntzogen || $erzwungenAus ? 0 : $sollstrom;
|
||||
}
|
||||
if (isset($daten['ama']) && is_numeric($daten['ama'])) {
|
||||
$maximalstrom = (int) $daten['ama'];
|
||||
if ($maximalstrom >= 6 && $maximalstrom <= 32) {
|
||||
$status['Maximalstrom_A'] = $maximalstrom;
|
||||
}
|
||||
}
|
||||
if (isset($daten['nrg'][4], $daten['nrg'][5], $daten['nrg'][6])
|
||||
&& is_numeric($daten['nrg'][4])
|
||||
&& is_numeric($daten['nrg'][5])
|
||||
&& is_numeric($daten['nrg'][6])
|
||||
) {
|
||||
$stromfaktor = $geraetetyp === self::GO_E_ALT ? 0.1 : 1.0;
|
||||
$phasenstroeme = [
|
||||
(float) $daten['nrg'][4] * $stromfaktor,
|
||||
(float) $daten['nrg'][5] * $stromfaktor,
|
||||
(float) $daten['nrg'][6] * $stromfaktor,
|
||||
];
|
||||
$status['Iststrom_A'] = max($phasenstroeme);
|
||||
$aktivePhasen = count(array_filter(
|
||||
$phasenstroeme,
|
||||
static fn (float $strom): bool => $strom >= 1.0
|
||||
));
|
||||
if ($aktivePhasen > 0) {
|
||||
$status['Phasenzahl'] = $aktivePhasen >= 2 ? 3 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
if ($geraetetyp === self::SMART_ME_PICO) {
|
||||
|
||||
+86
-27
@@ -12,30 +12,41 @@ final class LadestationRegler
|
||||
public const PHASEN_EIN = 1;
|
||||
public const PHASEN_DREI = 3;
|
||||
|
||||
/** @var list<int> */
|
||||
private const LEISTUNGEN_EINPHASIG = [
|
||||
1450, 1690, 1930, 2170, 2410, 2650, 2890, 3130, 3370,
|
||||
3610, 3850, 4090, 4330, 4570, 4810, 5050, 5290, 5530,
|
||||
5770, 6010, 6250, 6490, 6730, 6970, 7210, 7450, 7690,
|
||||
];
|
||||
private const LEISTUNG_PRO_AMPERE_EINPHASIG = 230;
|
||||
private const LEISTUNG_PRO_AMPERE_DREIPHASIG = 684;
|
||||
|
||||
/** @var list<int> */
|
||||
private const LEISTUNGEN_DREIPHASIG = [
|
||||
4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700,
|
||||
10300, 11000, 11750, 12450, 13150, 13850, 14550, 15250,
|
||||
15900, 16600, 17300, 18000, 18700, 19400, 20100, 20800,
|
||||
21500, 22000,
|
||||
];
|
||||
public static function erkennePhasenzahlAusLeistung(
|
||||
float $leistung,
|
||||
int $angeforderterStrom
|
||||
): int {
|
||||
if (!is_finite($leistung) || $leistung < 0.0) {
|
||||
throw new InvalidArgumentException('Istleistung muss endlich und darf nicht negativ sein.');
|
||||
}
|
||||
if ($angeforderterStrom < 6 || $angeforderterStrom > 32) {
|
||||
throw new InvalidArgumentException('Erkennungsstrom muss zwischen 6 und 32 A liegen.');
|
||||
}
|
||||
|
||||
$trennleistung = 2 * self::LEISTUNG_PRO_AMPERE_EINPHASIG * $angeforderterStrom;
|
||||
|
||||
return $leistung > $trennleistung ? self::PHASEN_DREI : self::PHASEN_EIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Schwelle, die Voll-Erkennung und der Zuschlag von 2.5 A stammen
|
||||
* aus der Enelix-1-Ladestation und bleiben bewusst kompatibel.
|
||||
* Die Schwelle und der Zuschlag von 2.5 A stammen aus der Enelix-1-Ladestation.
|
||||
* Ein Ladeende wird erst nach zuvor gemessener Ladeleistung angenommen, damit
|
||||
* die Anlaufzeit nach einer neuen Stromvorgabe nicht als volles Fahrzeug gilt.
|
||||
*
|
||||
* @param array{Rohstatus: int, Istleistung_W: float} $rohstatus
|
||||
* @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int} $rohstatus
|
||||
* @return array<string, int|float|bool>
|
||||
*/
|
||||
public static function normalisiereStatus(array $rohstatus, bool $warVerbunden): array
|
||||
{
|
||||
public static function normalisiereStatus(
|
||||
array $rohstatus,
|
||||
bool $warVerbunden,
|
||||
bool $warGeladen = false,
|
||||
bool $ladungErwartet = true,
|
||||
bool $ladeleistungWurdeErkannt = false,
|
||||
int $bekanntePhasenzahl = self::PHASEN_UNBEKANNT
|
||||
): array {
|
||||
$verbunden = $rohstatus['Rohstatus'] !== 1;
|
||||
$leistung = max(0.0, $rohstatus['Istleistung_W']);
|
||||
$phasen = self::PHASEN_UNBEKANNT;
|
||||
@@ -43,11 +54,28 @@ final class LadestationRegler
|
||||
$geladen = false;
|
||||
|
||||
if ($verbunden) {
|
||||
$phasen = $leistung > 7500.0 ? self::PHASEN_DREI : self::PHASEN_EIN;
|
||||
$nenner = $phasen === self::PHASEN_EIN ? 230.0 : 1.71 * 400.0;
|
||||
$ladestrom = $leistung / $nenner;
|
||||
if (in_array($bekanntePhasenzahl, [self::PHASEN_EIN, self::PHASEN_DREI], true)) {
|
||||
$phasen = $bekanntePhasenzahl;
|
||||
} elseif (isset($rohstatus['Phasenzahl'])
|
||||
&& in_array($rohstatus['Phasenzahl'], [self::PHASEN_EIN, self::PHASEN_DREI], true)
|
||||
) {
|
||||
$phasen = (int) $rohstatus['Phasenzahl'];
|
||||
} elseif ($leistung > 7500.0) {
|
||||
$phasen = self::PHASEN_DREI;
|
||||
}
|
||||
|
||||
$nenner = $phasen === self::PHASEN_DREI
|
||||
? self::LEISTUNG_PRO_AMPERE_DREIPHASIG
|
||||
: self::LEISTUNG_PRO_AMPERE_EINPHASIG;
|
||||
$ladestrom = isset($rohstatus['Iststrom_A'])
|
||||
? max(0.0, (float) $rohstatus['Iststrom_A'])
|
||||
: ($phasen === self::PHASEN_UNBEKANNT ? 0.0 : $leistung / $nenner);
|
||||
$ermittelterMaximalstrom = 2.5 + $ladestrom;
|
||||
$geladen = $warVerbunden && $ermittelterMaximalstrom < 6.0;
|
||||
$geladen = $warGeladen
|
||||
|| ($warVerbunden
|
||||
&& $ladungErwartet
|
||||
&& $ladeleistungWurdeErkannt
|
||||
&& $ermittelterMaximalstrom < 6.0);
|
||||
}
|
||||
|
||||
if (!$verbunden) {
|
||||
@@ -70,6 +98,29 @@ final class LadestationRegler
|
||||
];
|
||||
}
|
||||
|
||||
public static function ermittleFahrzeugMaximalstrom(
|
||||
int $vorgegebenerStrom,
|
||||
float $gemessenerStrom,
|
||||
int $konfigurierterMaximalstrom
|
||||
): ?int {
|
||||
if ($konfigurierterMaximalstrom < 6 || $konfigurierterMaximalstrom > 32) {
|
||||
throw new InvalidArgumentException('MaximalerLadestrom muss zwischen 6 und 32 A liegen.');
|
||||
}
|
||||
if ($vorgegebenerStrom < 6 || $gemessenerStrom < 5.5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$kandidat = max(
|
||||
6,
|
||||
min($konfigurierterMaximalstrom, (int) floor($gemessenerStrom + 2.5))
|
||||
);
|
||||
if ($kandidat > $vorgegebenerStrom - 2 || $kandidat >= $konfigurierterMaximalstrom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $kandidat;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public static function leistungsangebot(
|
||||
bool $aktiv,
|
||||
@@ -85,6 +136,10 @@ final class LadestationRegler
|
||||
return [0];
|
||||
}
|
||||
|
||||
if ($phasenzahl === self::PHASEN_UNBEKANNT) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
$stufen = self::leistungsstufen($phasenzahl, $maximalerLadestrom);
|
||||
if ($stufen === []) {
|
||||
return [0];
|
||||
@@ -135,12 +190,16 @@ final class LadestationRegler
|
||||
private static function stufenFuerPhasen(int $phasenzahl): array
|
||||
{
|
||||
if ($phasenzahl === self::PHASEN_EIN) {
|
||||
return self::LEISTUNGEN_EINPHASIG;
|
||||
}
|
||||
if ($phasenzahl === self::PHASEN_DREI) {
|
||||
return self::LEISTUNGEN_DREIPHASIG;
|
||||
$leistungProAmpere = self::LEISTUNG_PRO_AMPERE_EINPHASIG;
|
||||
} elseif ($phasenzahl === self::PHASEN_DREI) {
|
||||
$leistungProAmpere = self::LEISTUNG_PRO_AMPERE_DREIPHASIG;
|
||||
} else {
|
||||
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Phasenzahl muss 1 oder 3 sein.');
|
||||
return array_map(
|
||||
static fn (int $strom): int => $strom * $leistungProAmpere,
|
||||
range(6, 32)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ final class Lizenzpruefung
|
||||
{
|
||||
public const MANAGER_STANDARD = 'manager_standard';
|
||||
public const MANAGER_PEAK = 'manager_peak';
|
||||
public const PROGNOSE_PV = 'forecast_pv';
|
||||
public const PROGNOSE_VERBRAUCH = 'forecast_load';
|
||||
public const NETZFAHRPLAN = 'grid_schedule';
|
||||
|
||||
public static function normalisiereCode(string $code): string
|
||||
{
|
||||
@@ -82,6 +85,111 @@ final class Lizenzpruefung
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die aufsummierte aktive Berechtigung fuer einen Katalogeintrag.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function berechtigungsMenge(array $lease, string $kennung): int
|
||||
{
|
||||
$berechtigungen = $lease['entitlements'] ?? null;
|
||||
if (!is_array($berechtigungen)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$menge = 0;
|
||||
foreach ($berechtigungen as $berechtigung) {
|
||||
if (!is_array($berechtigung)
|
||||
|| ($berechtigung['status'] ?? null) !== 'active'
|
||||
|| !is_int($berechtigung['quantity'] ?? null)
|
||||
|| $berechtigung['quantity'] < 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$berechtigungsKennung = $berechtigung['catalog_key']
|
||||
?? $berechtigung['sku']
|
||||
?? null;
|
||||
if ($berechtigungsKennung === $kennung) {
|
||||
$menge += $berechtigung['quantity'];
|
||||
}
|
||||
}
|
||||
|
||||
return $menge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Prognose ist entweder durch den Netzfahrplan oder durch beide
|
||||
* Einzelberechtigungen fuer PV und Verbrauch freigeschaltet.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
public static function hatPrognoseBerechtigung(array $lease): bool
|
||||
{
|
||||
return self::berechtigungsMenge($lease, self::NETZFAHRPLAN) > 0
|
||||
|| (self::berechtigungsMenge($lease, self::PROGNOSE_PV) > 0
|
||||
&& self::berechtigungsMenge($lease, self::PROGNOSE_VERBRAUCH) > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet freie, bestehende Einzellizenzen auf nicht separat lizenzierte Waermepumpen an.
|
||||
*
|
||||
* @param array<string, mixed> $lease
|
||||
* @param array<string, int> $verwendeteMengen
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public static function beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
array $lease,
|
||||
array $verwendeteMengen
|
||||
): array {
|
||||
$waermepumpen = $verwendeteMengen['heat_pump'] ?? 0;
|
||||
if ($waermepumpen <= 0) {
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
$nichtSeparatLizenziert = max(
|
||||
0,
|
||||
$waermepumpen - self::berechtigungsMenge($lease, 'heat_pump')
|
||||
);
|
||||
$verbraucher = $verwendeteMengen['consumer_single'] ?? 0;
|
||||
$freieBestandslizenzen = max(
|
||||
0,
|
||||
self::berechtigungsMenge($lease, 'consumer_single') - $verbraucher
|
||||
);
|
||||
$angerechnet = min($nichtSeparatLizenziert, $freieBestandslizenzen);
|
||||
if ($angerechnet === 0) {
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
$verwendeteMengen['heat_pump'] = $waermepumpen - $angerechnet;
|
||||
$verwendeteMengen['consumer_single'] = $verbraucher + $angerechnet;
|
||||
|
||||
return $verwendeteMengen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
* @param array<string, int> $verwendeteMengen
|
||||
*/
|
||||
public static function pruefeVerbrauchermengen(array $lease, array $verwendeteMengen): void
|
||||
{
|
||||
foreach ($verwendeteMengen as $kennung => $verwendet) {
|
||||
if ($verwendet < 0) {
|
||||
throw new InvalidArgumentException(
|
||||
'Die verwendete Menge fuer ' . $kennung . ' darf nicht negativ sein.'
|
||||
);
|
||||
}
|
||||
$lizenziert = self::berechtigungsMenge($lease, $kennung);
|
||||
if ($verwendet > $lizenziert) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Lizenzkontingent fuer %s ueberschritten: %d verwendet, %d lizenziert.',
|
||||
$kennung,
|
||||
$verwendet,
|
||||
$lizenziert
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $lease
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+303
-15
@@ -19,6 +19,71 @@ final class ManagerRegler
|
||||
public const BETRIEBSART_PV = Nachrichtenvertrag::BETRIEBSART_PV;
|
||||
public const BETRIEBSART_PEAK = Nachrichtenvertrag::BETRIEBSART_PEAK;
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $punkte
|
||||
*/
|
||||
public static function interpolierteFahrplanVorgabe(
|
||||
array $punkte,
|
||||
int $jetzt,
|
||||
int $maximalerPunktabstandSekunden = 1800
|
||||
): ?float {
|
||||
if ($maximalerPunktabstandSekunden < 0) {
|
||||
throw new InvalidArgumentException('Maximaler Fahrplanpunktabstand darf nicht negativ sein.');
|
||||
}
|
||||
|
||||
$gueltigePunkte = [];
|
||||
foreach ($punkte as $punkt) {
|
||||
if (!is_array($punkt)
|
||||
|| !is_string($punkt['time'] ?? null)
|
||||
|| (!is_int($punkt['gridTargetW'] ?? null)
|
||||
&& !is_float($punkt['gridTargetW'] ?? null))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$zeitpunkt = strtotime($punkt['time']);
|
||||
$vorgabe = (float) $punkt['gridTargetW'];
|
||||
if ($zeitpunkt === false || !is_finite($vorgabe)) {
|
||||
continue;
|
||||
}
|
||||
$gueltigePunkte[$zeitpunkt] = $vorgabe;
|
||||
}
|
||||
if ($gueltigePunkte === []) {
|
||||
return null;
|
||||
}
|
||||
ksort($gueltigePunkte, SORT_NUMERIC);
|
||||
|
||||
$vorherigerZeitpunkt = null;
|
||||
$vorherigeVorgabe = null;
|
||||
foreach ($gueltigePunkte as $zeitpunkt => $vorgabe) {
|
||||
if ($zeitpunkt === $jetzt) {
|
||||
return $vorgabe;
|
||||
}
|
||||
if ($zeitpunkt > $jetzt) {
|
||||
if ($vorherigerZeitpunkt === null) {
|
||||
return $zeitpunkt - $jetzt <= $maximalerPunktabstandSekunden
|
||||
? $vorgabe
|
||||
: null;
|
||||
}
|
||||
if ($jetzt - $vorherigerZeitpunkt > $maximalerPunktabstandSekunden
|
||||
|| $zeitpunkt - $jetzt > $maximalerPunktabstandSekunden
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$anteil = ($jetzt - $vorherigerZeitpunkt) / ($zeitpunkt - $vorherigerZeitpunkt);
|
||||
|
||||
return $vorherigeVorgabe + (($vorgabe - $vorherigeVorgabe) * $anteil);
|
||||
}
|
||||
$vorherigerZeitpunkt = $zeitpunkt;
|
||||
$vorherigeVorgabe = $vorgabe;
|
||||
}
|
||||
|
||||
return $vorherigerZeitpunkt !== null
|
||||
&& $jetzt - $vorherigerZeitpunkt <= $maximalerPunktabstandSekunden
|
||||
? $vorherigeVorgabe
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float|int> $monatsgrenzen Monatsnummer 1 bis 12 als Schluessel
|
||||
*/
|
||||
@@ -92,6 +157,7 @@ final class ManagerRegler
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $verbraucher Vollstaendige, bereits gepruefte Verbraucherdaten
|
||||
* @param array<int, float|int> $bezogeneEnergieWh Bezogene Energie je Verbraucher in Wh
|
||||
* @return array{
|
||||
* Verteilbudget_W: int,
|
||||
* AktuelleVerbraucherleistung_W: int,
|
||||
@@ -104,7 +170,8 @@ final class ManagerRegler
|
||||
float $netzleistung,
|
||||
float $zielnetzleistung,
|
||||
array $verbraucher,
|
||||
string $prioritaetsfeld
|
||||
string $prioritaetsfeld,
|
||||
array $bezogeneEnergieWh = []
|
||||
): array {
|
||||
if (!in_array($prioritaetsfeld, ['PrioritaetPV', 'PrioritaetPeak'], true)) {
|
||||
throw new InvalidArgumentException('Unbekanntes Prioritaetsfeld.');
|
||||
@@ -120,44 +187,107 @@ final class ManagerRegler
|
||||
$aktuelleLeistung += $leistung;
|
||||
|
||||
$daten = $eintrag['Daten'];
|
||||
if (!$daten['Verfuegbar'] || !$daten['AenderungMoeglich'] || $daten['Leistungswerte_W'] === []) {
|
||||
if (!$daten['Verfuegbar'] || $daten['Leistungswerte_W'] === []) {
|
||||
$nichtSteuerbar += $leistung;
|
||||
continue;
|
||||
}
|
||||
|
||||
$festwert = self::istFestwert($daten['Leistungswerte_W']);
|
||||
$festgehalten = !$daten['AenderungMoeglich'];
|
||||
if ($festwert || $festgehalten) {
|
||||
$nichtSteuerbar += $leistung;
|
||||
}
|
||||
|
||||
$energie = $bezogeneEnergieWh[$eintrag['InstanzID']] ?? 0.0;
|
||||
if ((!is_int($energie) && !is_float($energie))
|
||||
|| (is_float($energie) && !is_finite($energie))
|
||||
) {
|
||||
throw new InvalidArgumentException('Bezogene Energie muss eine endliche Zahl sein.');
|
||||
}
|
||||
$steuerbar[] = [
|
||||
'InstanzID' => $eintrag['InstanzID'],
|
||||
'Prioritaet' => $daten[$prioritaetsfeld],
|
||||
'Energiegruppe' => (int) round((float) $energie / 2000.0),
|
||||
'Energie_Wh' => (float) $energie,
|
||||
'Leistungswerte_W' => $daten['Leistungswerte_W'],
|
||||
'Festwert' => $festwert,
|
||||
'Festgehalten' => $festgehalten,
|
||||
'Sollwert' => self::angenommenerSollwert($daten),
|
||||
];
|
||||
}
|
||||
|
||||
usort($steuerbar, static function (array $links, array $rechts): int {
|
||||
return [$links['Prioritaet'], $links['InstanzID']] <=> [$rechts['Prioritaet'], $rechts['InstanzID']];
|
||||
return [
|
||||
$links['Prioritaet'],
|
||||
$links['Energiegruppe'],
|
||||
$links['InstanzID'],
|
||||
] <=> [
|
||||
$rechts['Prioritaet'],
|
||||
$rechts['Energiegruppe'],
|
||||
$rechts['InstanzID'],
|
||||
];
|
||||
});
|
||||
|
||||
$verteilbudget = (int) round($aktuelleLeistung + $zielnetzleistung - $netzleistung);
|
||||
$positiveRichtung = $verteilbudget - $nichtSteuerbar >= 0;
|
||||
$sollwerte = [];
|
||||
$minimumSumme = 0;
|
||||
$ausgangssumme = 0;
|
||||
foreach ($steuerbar as $eintrag) {
|
||||
$minimum = self::kleinsterLeistungswert($eintrag['Leistungswerte_W']);
|
||||
$sollwerte[$eintrag['InstanzID']] = $minimum;
|
||||
$minimumSumme += $minimum;
|
||||
if ($eintrag['Festwert']) {
|
||||
$sollwerte[$eintrag['InstanzID']] = self::kleinsterLeistungswert(
|
||||
$eintrag['Leistungswerte_W']
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ($eintrag['Festgehalten']) {
|
||||
if ($eintrag['Sollwert'] !== null) {
|
||||
$sollwerte[$eintrag['InstanzID']] = $eintrag['Sollwert'];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$ausgangswert = self::richtungsneutralerLeistungswert(
|
||||
$eintrag['Leistungswerte_W'],
|
||||
$positiveRichtung
|
||||
);
|
||||
$sollwerte[$eintrag['InstanzID']] = $ausgangswert;
|
||||
$ausgangssumme += $ausgangswert;
|
||||
}
|
||||
|
||||
$verfuegbar = max(0, $verteilbudget - $nichtSteuerbar - $minimumSumme);
|
||||
$rest = $verteilbudget - $nichtSteuerbar - $ausgangssumme;
|
||||
$gruppen = [];
|
||||
foreach ($steuerbar as $eintrag) {
|
||||
if ($eintrag['Festwert'] || $eintrag['Festgehalten']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$instanzID = $eintrag['InstanzID'];
|
||||
$minimum = $sollwerte[$instanzID];
|
||||
$neuerSollwert = self::groessterLeistungswertBis(
|
||||
$eintrag['Leistungswerte_W'],
|
||||
$minimum + $verfuegbar
|
||||
);
|
||||
$verfuegbar -= $neuerSollwert - $minimum;
|
||||
$ausgangswert = $sollwerte[$instanzID];
|
||||
if ($positiveRichtung) {
|
||||
$gruppen[$eintrag['Prioritaet']][] = $eintrag;
|
||||
continue;
|
||||
} else {
|
||||
if ($rest >= 0) {
|
||||
continue;
|
||||
}
|
||||
$neuerSollwert = self::kleinsterLeistungswertAb(
|
||||
$eintrag['Leistungswerte_W'],
|
||||
$ausgangswert + $rest
|
||||
);
|
||||
}
|
||||
$rest -= $neuerSollwert - $ausgangswert;
|
||||
$sollwerte[$instanzID] = $neuerSollwert;
|
||||
}
|
||||
foreach ($gruppen as $gruppe) {
|
||||
self::verteilePositiveGruppe($gruppe, $sollwerte, $rest);
|
||||
}
|
||||
|
||||
$zugeteilt = $nichtSteuerbar + array_sum($sollwerte);
|
||||
$zugeteilt = $nichtSteuerbar;
|
||||
foreach ($steuerbar as $eintrag) {
|
||||
if (!$eintrag['Festwert'] && !$eintrag['Festgehalten']) {
|
||||
$zugeteilt += $sollwerte[$eintrag['InstanzID']];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'Verteilbudget_W' => $verteilbudget,
|
||||
@@ -168,6 +298,95 @@ final class ManagerRegler
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $gruppe Verbraucher derselben Prioritaet
|
||||
* @param array<int, int> $sollwerte
|
||||
*/
|
||||
private static function verteilePositiveGruppe(array $gruppe, array &$sollwerte, int &$rest): void
|
||||
{
|
||||
while ($rest > 0) {
|
||||
$kandidaten = self::naechsteLeistungsschritte($gruppe, $sollwerte, $rest);
|
||||
if ($kandidaten === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ganze Leistungsniveaus gemeinsam vergeben, ohne Bereiche Watt fuer Watt zu durchlaufen.
|
||||
$unten = $kandidaten[0]['NaechsteLeistung_W'] - 1;
|
||||
$oben = $unten;
|
||||
foreach ($kandidaten as $eintrag) {
|
||||
$oben = max($oben, $sollwerte[$eintrag['InstanzID']] + $rest);
|
||||
}
|
||||
while ($unten < $oben) {
|
||||
$mitte = $unten + intdiv($oben - $unten + 1, 2);
|
||||
$bedarf = 0;
|
||||
foreach ($kandidaten as $eintrag) {
|
||||
$bisher = $sollwerte[$eintrag['InstanzID']];
|
||||
$bedarf += max(0, self::groessterLeistungswertBis(
|
||||
$eintrag['Leistungswerte_W'],
|
||||
$mitte
|
||||
) - $bisher);
|
||||
if ($bedarf > $rest) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($bedarf <= $rest) {
|
||||
$unten = $mitte;
|
||||
} else {
|
||||
$oben = $mitte - 1;
|
||||
}
|
||||
}
|
||||
foreach ($kandidaten as $eintrag) {
|
||||
$id = $eintrag['InstanzID'];
|
||||
$neu = max($sollwerte[$id], self::groessterLeistungswertBis(
|
||||
$eintrag['Leistungswerte_W'],
|
||||
$unten
|
||||
));
|
||||
$rest -= $neu - $sollwerte[$id];
|
||||
$sollwerte[$id] = $neu;
|
||||
}
|
||||
|
||||
// Reicht es nicht fuer das ganze naechste Niveau, entscheidet Leistung, Energie, ID.
|
||||
$kandidaten = self::naechsteLeistungsschritte($gruppe, $sollwerte, $rest);
|
||||
if ($kandidaten === []) {
|
||||
return;
|
||||
}
|
||||
$erster = $kandidaten[0];
|
||||
$id = $erster['InstanzID'];
|
||||
$rest -= $erster['NaechsteLeistung_W'] - $sollwerte[$id];
|
||||
$sollwerte[$id] = $erster['NaechsteLeistung_W'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $gruppe
|
||||
* @param array<int, int> $sollwerte
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function naechsteLeistungsschritte(array $gruppe, array $sollwerte, int $rest): array
|
||||
{
|
||||
$kandidaten = [];
|
||||
foreach ($gruppe as $eintrag) {
|
||||
$bisher = $sollwerte[$eintrag['InstanzID']];
|
||||
foreach ($eintrag['Leistungswerte_W'] as $angebot) {
|
||||
$naechste = is_int($angebot) ? $angebot : max($angebot['Von_W'], $bisher + 1);
|
||||
if ($naechste <= $bisher || (!is_int($angebot) && $naechste > $angebot['Bis_W'])) {
|
||||
continue;
|
||||
}
|
||||
if ($naechste - $bisher <= $rest) {
|
||||
$eintrag['NaechsteLeistung_W'] = $naechste;
|
||||
$kandidaten[] = $eintrag;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
usort($kandidaten, static function (array $links, array $rechts): int {
|
||||
return [$links['NaechsteLeistung_W'], $links['Energie_Wh'], $links['InstanzID']]
|
||||
<=> [$rechts['NaechsteLeistung_W'], $rechts['Energie_Wh'], $rechts['InstanzID']];
|
||||
});
|
||||
|
||||
return $kandidaten;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $daten
|
||||
*/
|
||||
@@ -214,6 +433,53 @@ final class ManagerRegler
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein einzelner Leistungswert bleibt als Befehl erhalten. Fuer die
|
||||
* Leistungsbilanz zaehlt jedoch die gemessene Istleistung.
|
||||
*
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
private static function istFestwert(array $leistungswerte): bool
|
||||
{
|
||||
return count($leistungswerte) === 1 && is_int($leistungswerte[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
private static function richtungsneutralerLeistungswert(
|
||||
array $leistungswerte,
|
||||
bool $positiveRichtung
|
||||
): int {
|
||||
$negativ = null;
|
||||
$positiv = null;
|
||||
foreach ($leistungswerte as $eintrag) {
|
||||
if (is_int($eintrag)) {
|
||||
if ($eintrag === 0) {
|
||||
return 0;
|
||||
}
|
||||
$negativ = $eintrag < 0 ? max($negativ ?? $eintrag, $eintrag) : $negativ;
|
||||
$positiv = $eintrag > 0 ? min($positiv ?? $eintrag, $eintrag) : $positiv;
|
||||
continue;
|
||||
}
|
||||
if ($eintrag['Von_W'] <= 0 && $eintrag['Bis_W'] >= 0) {
|
||||
return 0;
|
||||
}
|
||||
$negativ = $eintrag['Bis_W'] < 0
|
||||
? max($negativ ?? $eintrag['Bis_W'], $eintrag['Bis_W'])
|
||||
: $negativ;
|
||||
$positiv = $eintrag['Von_W'] > 0
|
||||
? min($positiv ?? $eintrag['Von_W'], $eintrag['Von_W'])
|
||||
: $positiv;
|
||||
}
|
||||
|
||||
if ($positiveRichtung) {
|
||||
return $positiv ?? $negativ;
|
||||
}
|
||||
|
||||
return $negativ ?? $positiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
@@ -247,4 +513,26 @@ final class ManagerRegler
|
||||
|
||||
return $gewaehlt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
private static function kleinsterLeistungswertAb(array $leistungswerte, int $untergrenze): int
|
||||
{
|
||||
foreach ($leistungswerte as $eintrag) {
|
||||
if (is_int($eintrag)) {
|
||||
if ($eintrag >= $untergrenze) {
|
||||
return $eintrag;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($eintrag['Bis_W'] < $untergrenze) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return max($eintrag['Von_W'], $untergrenze);
|
||||
}
|
||||
|
||||
return self::richtungsneutralerLeistungswert($leistungswerte, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
trait ManagerSDLTrait
|
||||
{
|
||||
private function pruefeSDLKonfiguration(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
|
||||
return;
|
||||
}
|
||||
$leistungID = $this->ReadPropertyInteger('SDLLeistungVariableID');
|
||||
if (!$this->istNumerischeVariable($leistungID)) {
|
||||
throw new InvalidArgumentException('SDL benoetigt eine numerische Istleistungsvariable.');
|
||||
}
|
||||
$this->anlagenMessfaktor($this->ReadPropertyFloat('SDLLeistungsfaktor'), 'SDL');
|
||||
$socID = $this->ReadPropertyInteger('SDLSOCVariableID');
|
||||
if ($socID !== 0 && (!$this->istNumerischeVariable($socID) || $socID === $leistungID)) {
|
||||
throw new InvalidArgumentException('SDL-SOC muss eine separate numerische Variable sein.');
|
||||
}
|
||||
$andereQuellen = array_merge($this->pvMessquellen(), $this->batterieMessquellen());
|
||||
$andereQuellen[] = ['VariableID' => $this->ReadPropertyInteger('NetzleistungVariableID')];
|
||||
foreach ($andereQuellen as $quelle) {
|
||||
if ($leistungID === $quelle['VariableID']) {
|
||||
throw new InvalidArgumentException(
|
||||
'SDL-Leistung darf nicht zugleich als PV-, Netz- oder Batterieleistung bilanziert werden.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function synchronisiereSDLMesskonfiguration(): void
|
||||
{
|
||||
$neu = json_encode([
|
||||
$this->ReadPropertyBoolean('SDLAktiv'),
|
||||
$this->ReadPropertyInteger('SDLLeistungVariableID'),
|
||||
$this->ReadPropertyFloat('SDLLeistungsfaktor'),
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$alt = $this->ReadAttributeString('SDLMesskonfiguration');
|
||||
if ($neu !== $alt && ($alt !== '' || $this->ReadPropertyBoolean('SDLAktiv'))) {
|
||||
// Bereits erfasste Energien bleiben erhalten; keine Rueckrechnung mit neuen Quellen.
|
||||
$this->WriteAttributeInteger('MessungLetzterZeitstempel', 0);
|
||||
$this->WriteAttributeString('MessungZaehlerstaende', '{}');
|
||||
$this->WriteAttributeInteger('MessungLetzteLeistungsaufzeichnung', 0);
|
||||
}
|
||||
$this->WriteAttributeString('SDLMesskonfiguration', $neu);
|
||||
}
|
||||
|
||||
private function aktuelleSDLLeistung(): float
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
|
||||
return 0.0;
|
||||
}
|
||||
$variableID = $this->ReadPropertyInteger('SDLLeistungVariableID');
|
||||
if (!$this->istNumerischeVariable($variableID)) {
|
||||
throw new RuntimeException('Die SDL-Istleistung ist nicht verfuegbar.');
|
||||
}
|
||||
$wert = GetValue($variableID);
|
||||
if ((!is_int($wert) && !is_float($wert)) || !is_finite((float) $wert)) {
|
||||
throw new RuntimeException('Die SDL-Istleistung ist keine endliche Zahl.');
|
||||
}
|
||||
$leistung = (float) $wert * $this->ReadPropertyFloat('SDLLeistungsfaktor');
|
||||
if (!is_finite($leistung)) {
|
||||
throw new RuntimeException('Die skalierte SDL-Istleistung ist nicht endlich.');
|
||||
}
|
||||
if ($this->ReadPropertyInteger('SDLSOCVariableID') === 0 || $this->aktuelleSDLSOC() !== null) {
|
||||
$this->setzeDiagnosewert('SDLStatus', 'Aktiv');
|
||||
}
|
||||
|
||||
return $leistung;
|
||||
}
|
||||
|
||||
private function aktuelleSDLSOC(): ?float
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
|
||||
return null;
|
||||
}
|
||||
$variableID = $this->ReadPropertyInteger('SDLSOCVariableID');
|
||||
if ($variableID === 0) {
|
||||
return null;
|
||||
}
|
||||
if ($this->istNumerischeVariable($variableID)) {
|
||||
$wert = (float) GetValue($variableID);
|
||||
if (is_finite($wert) && $wert >= 0.0 && $wert <= 100.0) {
|
||||
return $wert;
|
||||
}
|
||||
}
|
||||
$this->setzeDiagnosewert('SDLStatus', 'Fehler: SDL-SOC muss zwischen 0 und 100 Prozent liegen.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function sdlAnzeigen(string $property): bool
|
||||
{
|
||||
return $this->ReadPropertyBoolean('SDLAktiv') && $this->ReadPropertyBoolean($property);
|
||||
}
|
||||
|
||||
private function aktualisiereSDLStatus(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('SDLAktiv')) {
|
||||
$this->setzeDiagnosewert('SDLStatus', 'NichtVerwendet');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$this->aktuelleSDLLeistung();
|
||||
$this->aktuelleSDLSOC();
|
||||
} catch (Throwable $fehler) {
|
||||
$this->setzeDiagnosewert('SDLStatus', 'Fehler: ' . $fehler->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function synchronisiereSDLEnergiefluss(int $instanzID, bool $profilAktualisieren = false): void
|
||||
{
|
||||
$anzeigen = $this->sdlAnzeigen('SDLEnergieflussAnzeigen');
|
||||
$variableID = $this->findeUnterobjekt('EnergieflussSDL');
|
||||
if (!$anzeigen && $variableID === 0) {
|
||||
return;
|
||||
}
|
||||
if ($anzeigen && ($variableID === 0 || $profilAktualisieren)) {
|
||||
$variableID = $this->energieflussLeistungsvariable(
|
||||
'EnergieflussSDL', 'Energiefluss SDL', $this->energieflussLeistungsprofil(), 635
|
||||
);
|
||||
}
|
||||
$alt = json_decode(IPS_GetProperty($instanzID, 'Config'), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($alt) || !array_is_list($alt)) {
|
||||
throw new RuntimeException('Die bestehende Energiefluss-Konfiguration ist keine Liste.');
|
||||
}
|
||||
$neu = [];
|
||||
$gefunden = false;
|
||||
$socID = $this->aktuelleSDLSOC() !== null
|
||||
? $this->GetIDForIdent('SDLSOCArchiv') : 1;
|
||||
foreach ($alt as $knoten) {
|
||||
if ((int) ($knoten['VariableID'] ?? 0) !== $variableID) {
|
||||
$neu[] = $knoten;
|
||||
} elseif ($anzeigen && !$gefunden) {
|
||||
$knoten['InfoVariableID'] = $socID;
|
||||
$neu[] = $knoten;
|
||||
$gefunden = true;
|
||||
}
|
||||
}
|
||||
if ($anzeigen && !$gefunden) {
|
||||
$neu[] = $this->energieflussKnoten($variableID, 'SDL / Regelenergie', 'storage', false, $socID);
|
||||
}
|
||||
if ($neu !== $alt) {
|
||||
IPS_SetProperty($instanzID, 'Config', json_encode($neu, JSON_THROW_ON_ERROR));
|
||||
IPS_ApplyChanges($instanzID);
|
||||
}
|
||||
}
|
||||
|
||||
private function synchronisiereSDLDiagramm(int $medienID, bool $energie): void
|
||||
{
|
||||
$idents = $energie
|
||||
? ['SDLLadenEnergie', 'SDLEntladenEnergie', 'SDLSOCArchiv']
|
||||
: ['SDLLeistungArchiv', 'SDLSOCArchiv'];
|
||||
$verwaltet = array_map(fn (string $ident): int => $this->GetIDForIdent($ident), $idents);
|
||||
$gewuenscht = [];
|
||||
if ($this->sdlAnzeigen('SDLDiagrammeAnzeigen')) {
|
||||
if ($energie) {
|
||||
$gewuenscht[] = $this->chartDatensatz(
|
||||
'SDLLadenEnergie', 'SDL laden', '#007C91', '~Electricity', 'bar'
|
||||
);
|
||||
$gewuenscht[] = $this->chartDatensatz(
|
||||
'SDLEntladenEnergie', 'SDL entladen', '#C43E1C', '~Electricity', 'bar'
|
||||
);
|
||||
} else {
|
||||
$gewuenscht[] = $this->chartDatensatz(
|
||||
'SDLLeistungArchiv', 'SDL Istleistung', '#007C91', '~Power'
|
||||
);
|
||||
}
|
||||
if ($this->ReadPropertyInteger('SDLSOCVariableID') > 0) {
|
||||
$soc = $this->chartDatensatz('SDLSOCArchiv', 'SDL SOC', '#C43E1C', '~Intensity.100');
|
||||
$soc['side'] = 'right';
|
||||
$gewuenscht[] = $soc;
|
||||
}
|
||||
}
|
||||
$inhalt = json_decode((string) base64_decode(IPS_GetMediaContent($medienID), true), true);
|
||||
if (!is_array($inhalt) || !isset($inhalt['datasets']) || !is_array($inhalt['datasets'])) {
|
||||
if (!$this->sdlAnzeigen('SDLDiagrammeAnzeigen')) {
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Das bestehende Diagramm hat keine gueltigen Datensaetze.');
|
||||
}
|
||||
$alt = $inhalt['datasets'];
|
||||
$neu = [];
|
||||
foreach ($alt as $datensatz) {
|
||||
$id = (int) ($datensatz['variableID'] ?? 0);
|
||||
if (!in_array($id, $verwaltet, true)) {
|
||||
$neu[] = $datensatz;
|
||||
continue;
|
||||
}
|
||||
foreach ($gewuenscht as $index => $ziel) {
|
||||
if ($id === $ziel['variableID']) {
|
||||
$neu[] = $datensatz;
|
||||
unset($gewuenscht[$index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$neu = array_merge($neu, array_values($gewuenscht));
|
||||
if ($neu !== $alt) {
|
||||
$inhalt['datasets'] = $neu;
|
||||
IPS_SetMediaContent($medienID, base64_encode(json_encode($inhalt, JSON_THROW_ON_ERROR)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,33 @@ final class Nachrichtenvertrag
|
||||
self::pruefeZustand($daten['Zustand']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
|
||||
*/
|
||||
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
|
||||
{
|
||||
foreach ($leistungswerte as $eintrag) {
|
||||
if (is_int($eintrag)) {
|
||||
if ($eintrag === $leistung) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($eintrag)
|
||||
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
|
||||
&& is_int($eintrag['Von_W'])
|
||||
&& is_int($eintrag['Bis_W'])
|
||||
&& $leistung >= $eintrag['Von_W']
|
||||
&& $leistung <= $eintrag['Bis_W']
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $kopf
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class StoerungsSnapshot
|
||||
{
|
||||
public const VERSION = '1.0';
|
||||
|
||||
private const SCHWEREGRADE = [
|
||||
'critical' => 0,
|
||||
'error' => 1,
|
||||
'warning' => 2,
|
||||
'info' => 3,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $meldungen
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function erstellen(
|
||||
string $installationID,
|
||||
int $managerID,
|
||||
string $rolle,
|
||||
bool $managerAktiv,
|
||||
array $meldungen,
|
||||
?int $zeitpunkt = null
|
||||
): array {
|
||||
if (preg_match(
|
||||
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
|
||||
$installationID
|
||||
) !== 1) {
|
||||
throw new InvalidArgumentException('Die Installations-ID muss eine UUIDv4 sein.');
|
||||
}
|
||||
if ($managerID <= 0) {
|
||||
throw new InvalidArgumentException('Die Manager-ID muss positiv sein.');
|
||||
}
|
||||
if (!in_array($rolle, ['standalone', 'main', 'submanager'], true)) {
|
||||
throw new InvalidArgumentException('Die Managerrolle ist ungueltig.');
|
||||
}
|
||||
|
||||
$eindeutig = [];
|
||||
foreach ($meldungen as $meldung) {
|
||||
$normalisiert = self::normalisiereMeldung($meldung);
|
||||
$schluessel = $normalisiert['sourceId'] . "\0" . $normalisiert['code'];
|
||||
$eindeutig[$schluessel] = $normalisiert;
|
||||
}
|
||||
$meldungen = array_values($eindeutig);
|
||||
usort($meldungen, static function (array $links, array $rechts): int {
|
||||
$schweregrad = self::SCHWEREGRADE[$links['severity']]
|
||||
<=> self::SCHWEREGRADE[$rechts['severity']];
|
||||
if ($schweregrad !== 0) {
|
||||
return $schweregrad;
|
||||
}
|
||||
|
||||
return [$links['sourceName'], $links['code']]
|
||||
<=> [$rechts['sourceName'], $rechts['code']];
|
||||
});
|
||||
// Das Portal akzeptiert hoechstens 100 Meldungen; die hoechste Prioritaet bleibt erhalten.
|
||||
$meldungen = array_slice($meldungen, 0, 100);
|
||||
|
||||
return [
|
||||
'version' => self::VERSION,
|
||||
'installationId' => strtolower($installationID),
|
||||
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z', $zeitpunkt ?? time()),
|
||||
'manager' => [
|
||||
'instanceId' => $managerID,
|
||||
'role' => $rolle,
|
||||
'active' => $managerAktiv,
|
||||
],
|
||||
'faults' => $meldungen,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $snapshot */
|
||||
public static function zustandHash(array $snapshot): string
|
||||
{
|
||||
unset($snapshot['capturedAt']);
|
||||
|
||||
return hash('sha256', json_encode(
|
||||
$snapshot,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meldung
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function normalisiereMeldung(array $meldung): array
|
||||
{
|
||||
$felder = ['sourceType', 'sourceId', 'sourceName', 'code', 'severity', 'message'];
|
||||
if (array_diff($felder, array_keys($meldung)) !== []
|
||||
|| array_diff(array_keys($meldung), $felder) !== []
|
||||
) {
|
||||
throw new InvalidArgumentException('Eine Stoerungsmeldung hat ungueltige Felder.');
|
||||
}
|
||||
|
||||
$normalisiert = [];
|
||||
foreach ($felder as $feld) {
|
||||
if (!is_string($meldung[$feld])) {
|
||||
throw new InvalidArgumentException('Stoerungsfelder muessen Texte sein.');
|
||||
}
|
||||
$normalisiert[$feld] = trim((string) preg_replace('/\s+/', ' ', $meldung[$feld]));
|
||||
}
|
||||
if ($normalisiert['sourceType'] === ''
|
||||
|| $normalisiert['sourceId'] === ''
|
||||
|| $normalisiert['sourceName'] === ''
|
||||
|| $normalisiert['code'] === ''
|
||||
|| $normalisiert['message'] === ''
|
||||
) {
|
||||
throw new InvalidArgumentException('Stoerungsfelder duerfen nicht leer sein.');
|
||||
}
|
||||
if (!array_key_exists($normalisiert['severity'], self::SCHWEREGRADE)) {
|
||||
throw new InvalidArgumentException('Der Stoerungsschweregrad ist ungueltig.');
|
||||
}
|
||||
if (strlen($normalisiert['sourceType']) > 40
|
||||
|| strlen($normalisiert['sourceId']) > 120
|
||||
|| strlen($normalisiert['sourceName']) > 160
|
||||
|| strlen($normalisiert['code']) > 120
|
||||
|| strlen($normalisiert['message']) > 500
|
||||
) {
|
||||
throw new InvalidArgumentException('Mindestens ein Stoerungsfeld ist zu lang.');
|
||||
}
|
||||
|
||||
return $normalisiert;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,31 @@ declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
/**
|
||||
* Registriert die gemeinsamen Properties und Variablen aller EMS-Verbraucher.
|
||||
* Gemeinsame Registrierung und Sollwert-Bereinigung aller EMS-Verbraucher.
|
||||
*
|
||||
* Das Trait ist noch keine vollstaendige Steuerungsimplementierung. Die
|
||||
* konkreten Module ergaenzen Geraeteanbindung, Leistungsangebot und Zustand.
|
||||
*/
|
||||
trait VerbraucherBasisTrait
|
||||
{
|
||||
protected function verwerfeSollwert(): void
|
||||
{
|
||||
// Lokale Ersatzleistungen nicht bei jedem weiteren Timeout kurz auf 0 setzen.
|
||||
if (!(bool) $this->leseZustand('SollwertGueltig')) {
|
||||
return;
|
||||
}
|
||||
$this->setzeZustand('SollwertGueltig', false);
|
||||
$this->setzeZustand('Sollleistung', 0);
|
||||
}
|
||||
|
||||
protected function bereinigeUngueltigenSollwert(): void
|
||||
{
|
||||
// Auch vor dem Update gespeicherte, bereits ungueltige Vorgaben bereinigen.
|
||||
if (!(bool) $this->leseZustand('SollwertGueltig')) {
|
||||
$this->setzeZustand('Sollleistung', 0);
|
||||
}
|
||||
}
|
||||
|
||||
protected function registriereVerbraucherBasis(): void
|
||||
{
|
||||
$this->RegisterPropertyInteger('PrioritaetPV', 0);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\Anlagentopologie;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AnlagentopologieTest extends TestCase
|
||||
{
|
||||
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
|
||||
{
|
||||
$topologie = Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'hybrid-1',
|
||||
'Name' => 'Hybrid Ost',
|
||||
'Typ' => 'hybrid',
|
||||
'ACNennleistung_kW' => 10.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
json_encode([
|
||||
[
|
||||
'ID' => 'pv-ost',
|
||||
'Name' => 'Ostdach',
|
||||
'DCLeistung_kWp' => 6.4,
|
||||
'Neigung_Grad' => 25.0,
|
||||
'Azimut_Grad' => -90.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'MPPT' => 'A',
|
||||
'Modulanzahl' => 16,
|
||||
'Modulleistung_Wp' => 400.0,
|
||||
],
|
||||
[
|
||||
'ID' => 'pv-west',
|
||||
'Name' => 'Westdach',
|
||||
'DCLeistung_kWp' => 6.4,
|
||||
'Neigung_Grad' => 25.0,
|
||||
'Azimut_Grad' => 90.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'MPPT' => 'B',
|
||||
'Modulanzahl' => 16,
|
||||
'Modulleistung_Wp' => 400.0,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
json_encode([[
|
||||
'ID' => 'bat-1',
|
||||
'Name' => 'Hausspeicher',
|
||||
'Nennkapazitaet_kWh' => 13.5,
|
||||
'Nutzkapazitaet_kWh' => 12.8,
|
||||
'MaxLadeleistung_kW' => 5.0,
|
||||
'MaxEntladeleistung_kW' => 5.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'Kopplung' => 'hybrid',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
|
||||
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
|
||||
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
|
||||
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
|
||||
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
|
||||
self::assertArrayNotHasKey('acExportLimitKw', $topologie['inverters'][0]);
|
||||
self::assertArrayNotHasKey('acImportLimitKw', $topologie['inverters'][0]);
|
||||
self::assertSame([[
|
||||
'inverterId' => 'hybrid-1',
|
||||
'acRatedKw' => 10.0,
|
||||
'pvArrayIds' => ['pv-ost', 'pv-west'],
|
||||
'batteryIds' => ['bat-1'],
|
||||
]], $topologie['sharedAcLimits']);
|
||||
}
|
||||
|
||||
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
|
||||
{
|
||||
$topologie = Anlagentopologie::ausJson(
|
||||
'[]',
|
||||
'[]',
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
|
||||
self::assertSame([], $topologie['inverters']);
|
||||
self::assertSame([], $topologie['pvArrays']);
|
||||
self::assertSame([], $topologie['batteries']);
|
||||
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
|
||||
}
|
||||
|
||||
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
'[]',
|
||||
json_encode([[
|
||||
'ID' => 'pv-1',
|
||||
'DCLeistung_kWp' => 5.0,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'wr-fehlt',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
|
||||
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('DC- und Hybridkopplung');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'bat-wr',
|
||||
'Typ' => 'battery',
|
||||
'ACNennleistung_kW' => 5.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
json_encode([[
|
||||
'ID' => 'bat-1',
|
||||
'Nennkapazitaet_kWh' => 10.0,
|
||||
'Nutzkapazitaet_kWh' => 9.0,
|
||||
'MaxLadeleistung_kW' => 4.0,
|
||||
'MaxEntladeleistung_kW' => 4.0,
|
||||
'WechselrichterID' => 'bat-wr',
|
||||
'Kopplung' => 'dc',
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
|
||||
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
|
||||
|
||||
Anlagentopologie::ausJson(
|
||||
json_encode([[
|
||||
'ID' => 'pv-wr',
|
||||
'Typ' => 'pv',
|
||||
'ACNennleistung_kW' => 5.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
json_encode([[
|
||||
'ID' => 'pv-1',
|
||||
'DCLeistung_kWp' => 6.0,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'pv-wr',
|
||||
'Modulanzahl' => 15,
|
||||
'Modulleistung_Wp' => 0.0,
|
||||
]], JSON_THROW_ON_ERROR),
|
||||
'[]',
|
||||
'2a518707-8d62-4d97-82ce-606c95726500'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,30 @@ final class BatterieModulstrukturTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testNachladebegrenzungIstImFormularKonfigurierbar(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
|
||||
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyBoolean('NachladenMitMaximalleistung', true)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('MaximaleNachladeleistung', 0)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"'FormNachladenMitMaximalleistung'",
|
||||
$formular
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
'"name": "MaximaleNachladeleistung"',
|
||||
$formular
|
||||
);
|
||||
self::assertStringContainsString('"suffix": " W"', $formular);
|
||||
}
|
||||
|
||||
public function testLoggingDiagnoseUndAktionsbuttonsSindVorhanden(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
@@ -10,14 +10,14 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class BatterieReglerTest extends TestCase
|
||||
{
|
||||
public function testLeistungsrasterBehaeltGrobeUndFeineSchritte(): void
|
||||
public function testLeistungsbereichBleibtInnerhalbDynamischerGrenzen(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
-1000, -750, -500, -250, -200, -150, -100, -50,
|
||||
0, 50, 100, 150, 200, 250, 500, 750, 1000,
|
||||
],
|
||||
BatterieRegler::leistungsraster(1000.0, 1000.0)
|
||||
[['Von_W' => -999, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.9, 999.9
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,10 @@ final class BatterieReglerTest extends TestCase
|
||||
self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true));
|
||||
}
|
||||
|
||||
public function testPvAngebotEntsprichtDemBisherigenSocVerhalten(): void
|
||||
public function testPvAngebotVerwendetStufenloseBereicheMitBisherigerSocLogik(): void
|
||||
{
|
||||
$voll = BatterieRegler::leistungsraster(1000.0, 1000.0);
|
||||
|
||||
self::assertSame(
|
||||
$voll,
|
||||
[['Von_W' => -1000, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
@@ -47,7 +45,7 @@ final class BatterieReglerTest extends TestCase
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[-1000, -750, -500, -250, -200, -150, -100, -50, 0],
|
||||
[['Von_W' => -1000, 'Bis_W' => 0]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 100.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
@@ -66,6 +64,56 @@ final class BatterieReglerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testPeakEinschraenkungenWerdenAlsBereicheGemeldet(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 0]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 21.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => 0, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 10.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testNachladeleistungBegrenztNurSchutzbedingtesLaden(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[400],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 19.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0, 400.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => 0, 'Bis_W' => 400]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 10.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0, 400.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 400]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, true, 15.0, 20.0, 10.0, true,
|
||||
0.0, 0.0, 1000.0, 1000.0, 400.0
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
[['Von_W' => -1000, 'Bis_W' => 1000]],
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, 1000.0, 1000.0, 400.0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testWechselrichtersteuerungBietetNurNull(): void
|
||||
{
|
||||
self::assertSame(
|
||||
@@ -186,6 +234,9 @@ final class BatterieReglerTest extends TestCase
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
BatterieRegler::leistungsraster(-1.0, 1000.0);
|
||||
BatterieRegler::leistungsangebot(
|
||||
true, true, false, 50.0, 20.0, 10.0, false,
|
||||
0.0, 0.0, -1.0, 1000.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,12 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
'boiler1' => 2400,
|
||||
'boiler2' => 1500,
|
||||
'dryer' => 900,
|
||||
'buffer' => 1800,
|
||||
'battery' => -1200,
|
||||
'temp1' => 51.2,
|
||||
'temp2' => 48.7,
|
||||
'bufferTemp' => 39.4,
|
||||
'batterySoc' => 63.5,
|
||||
'manager' => 'PV',
|
||||
]);
|
||||
|
||||
@@ -27,6 +31,9 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
self::assertStringContainsString('Boiler 1', $html);
|
||||
self::assertStringContainsString('Boiler 2', $html);
|
||||
self::assertStringContainsString('Entfeuchter', $html);
|
||||
self::assertStringContainsString('Pufferspeicher', $html);
|
||||
self::assertStringContainsString('Batterie', $html);
|
||||
self::assertStringContainsString('63,5 %', $html);
|
||||
self::assertStringContainsString('Einspeisung', $html);
|
||||
self::assertStringContainsString('12:30', $html);
|
||||
}
|
||||
@@ -105,6 +112,31 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
self::assertStringContainsString('ENELIX_DEMO_DUMMY_MODULE', $source);
|
||||
self::assertStringContainsString("'Boiler1Liste'", $source);
|
||||
self::assertStringContainsString("'Boiler2Liste'", $source);
|
||||
self::assertStringContainsString("'PufferListe'", $source);
|
||||
self::assertStringContainsString("'BatterieListe'", $source);
|
||||
self::assertStringContainsString('ENELIX_DEMO_BUFFER_MODULE', $source);
|
||||
self::assertStringContainsString('ENELIX_DEMO_BATTERY_MODULE', $source);
|
||||
self::assertStringContainsString("'AnlagenWechselrichter'", $source);
|
||||
self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
|
||||
self::assertStringContainsString("'AnlagenBatterien'", $source);
|
||||
self::assertStringContainsString("'EnergyPieAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'EnergiediagrammeAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'FunFactsAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'EnergieflussAnzeigen' => true", $source);
|
||||
self::assertStringContainsString("'LeistungVariableID' => \$batteryActual", $source);
|
||||
self::assertStringContainsString("'SOCVariableID' => \$batterySoc", $source);
|
||||
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
|
||||
self::assertStringContainsString("'IstleistungVariableID' => \$inverterActual", $source);
|
||||
self::assertStringContainsString("'BegrenzungVariableID' => \$inverterLimit", $source);
|
||||
self::assertStringContainsString("'Begrenzungsart' => 'watt'", $source);
|
||||
self::assertStringContainsString("'EinspeisebegrenzungAktiv' => true", $source);
|
||||
self::assertStringContainsString("'Einspeisegrenze' => 3000.0", $source);
|
||||
self::assertStringContainsString("'PrognoseAktiv' => false", $source);
|
||||
self::assertStringContainsString("'NetzfahrplanAktiv' => false", $source);
|
||||
self::assertStringNotContainsString("'Einspeisetoleranz'", $source);
|
||||
self::assertStringContainsString("'demo-hybrid-1'", $source);
|
||||
self::assertStringContainsString("'demo-pv-1'", $source);
|
||||
self::assertStringContainsString("'demo-battery-1'", $source);
|
||||
self::assertStringContainsString("'EnergieflussPVkW'", $source);
|
||||
self::assertStringContainsString("'~Power'", $source);
|
||||
self::assertStringContainsString("'~Electricity'", $source);
|
||||
@@ -120,6 +152,20 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
"enelixDemoEnergyNode(\$flowDryer, 'Entfeuchter', 'consumer')",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowBuffer, 'Pufferspeicher', 'consumer', \$bufferTemp)",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnergyNode(\$flowBattery, 'Batteriespeicher', 'battery', \$batterySoc)",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"\$consumerIds = [\$boiler1, \$boiler2, \$dryer, \$buffer, \$battery]",
|
||||
$source
|
||||
);
|
||||
self::assertStringContainsString("'consumerIds' => \$consumerIds", $source);
|
||||
self::assertStringContainsString("'VerbraucherZuordnung' => '[]'", $source);
|
||||
self::assertStringContainsString(
|
||||
"enelixDemoEnsureChart(\$history, 'LeistungsdiagrammNativ'",
|
||||
$source
|
||||
@@ -135,6 +181,35 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testDynamischeUmgebungBleibtBegrenztUndVeraendertDenLastgang(): void
|
||||
{
|
||||
$static = enelixDemoEnvironment(12.0, 1000.0, 30, false);
|
||||
self::assertSame(['base' => 1000.0, 'cloud' => 30.0, 'outside' => 6.0], $static);
|
||||
|
||||
$morning = enelixDemoEnvironment(7.5, 1000.0, 30, true);
|
||||
$night = enelixDemoEnvironment(2.0, 1000.0, 30, true);
|
||||
self::assertGreaterThan($night['base'], $morning['base']);
|
||||
self::assertGreaterThanOrEqual(0.0, $morning['cloud']);
|
||||
self::assertLessThanOrEqual(100.0, $morning['cloud']);
|
||||
self::assertNotSame($morning['outside'], $night['outside']);
|
||||
}
|
||||
|
||||
public function testBatterieregisterWerdenMitKorrektemVorzeichenAbgebildet(): void
|
||||
{
|
||||
self::assertSame(1500.0, enelixDemoBatteryRegisterPower(2, 11, 1500.0));
|
||||
self::assertSame(-1500.0, enelixDemoBatteryRegisterPower(2, 12, 1500.0));
|
||||
self::assertSame(0.0, enelixDemoBatteryRegisterPower(1, 11, 1500.0));
|
||||
self::assertSame(0.0, enelixDemoBatteryRegisterPower(2, 0, 1500.0));
|
||||
}
|
||||
|
||||
public function testWechselrichtergrenzeBegrenztNurDieTatsaechlichePvLeistung(): void
|
||||
{
|
||||
self::assertSame(3000.0, enelixDemoLimitPvPower(8000.0, 3000.0));
|
||||
self::assertSame(2500.0, enelixDemoLimitPvPower(2500.0, 3000.0));
|
||||
self::assertSame(0.0, enelixDemoLimitPvPower(-100.0, 3000.0));
|
||||
self::assertSame(0.0, enelixDemoLimitPvPower(8000.0, -100.0));
|
||||
}
|
||||
|
||||
public function testRollbackIstExplizitBestaetigungspflichtig(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
|
||||
@@ -45,6 +45,37 @@ final class EaseeGatewayProtokollTest extends TestCase
|
||||
self::assertSame([], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testRestBeobachtungenVerwendenAngefragteSeriennummer(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
['id' => 109, 'value' => 1],
|
||||
['observationId' => 110, 'value' => 30],
|
||||
], ['EU2FGKEU'], 'EU2FGKEU');
|
||||
|
||||
self::assertSame([
|
||||
['Seriennummer' => 'EU2FGKEU', 'ID' => 109, 'Wert' => 1],
|
||||
['Seriennummer' => 'EU2FGKEU', 'ID' => 110, 'Wert' => 30],
|
||||
], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testFreigabediagnoseWirdAusBeobachtungenExtrahiert(): void
|
||||
{
|
||||
$beobachtungen = EaseeGatewayProtokoll::extrahiereBeobachtungen([
|
||||
'serialNumber' => 'EH123456',
|
||||
'observations' => [
|
||||
['id' => 42, 'value' => true],
|
||||
['id' => 43, 'value' => true],
|
||||
['id' => 96, 'value' => 55],
|
||||
],
|
||||
], ['EH123456']);
|
||||
|
||||
self::assertSame([
|
||||
['Seriennummer' => 'EH123456', 'ID' => 42, 'Wert' => true],
|
||||
['Seriennummer' => 'EH123456', 'ID' => 43, 'Wert' => true],
|
||||
['Seriennummer' => 'EH123456', 'ID' => 96, 'Wert' => 55],
|
||||
], $beobachtungen);
|
||||
}
|
||||
|
||||
public function testSeriennummerWirdNormalisiert(): void
|
||||
{
|
||||
self::assertSame(
|
||||
|
||||
@@ -77,6 +77,33 @@ final class EaseeLadestatusTest extends TestCase
|
||||
self::assertSame(0, $status['Phasenzahl']);
|
||||
}
|
||||
|
||||
public function testStillstehendeWallboxSetztVeralteteLeistungUndStromAufNull(): void
|
||||
{
|
||||
$status = EaseeLadestatus::ausBeobachtungen([
|
||||
'109' => 2,
|
||||
'110' => 30,
|
||||
'120' => 10.294,
|
||||
'182' => 14.9,
|
||||
'183' => 14.8,
|
||||
'184' => 14.7,
|
||||
], 32);
|
||||
|
||||
self::assertEqualsWithDelta(0.0, $status['Istleistung_W'], 0.01);
|
||||
self::assertEqualsWithDelta(0.0, $status['Ladestrom_A'], 0.01);
|
||||
}
|
||||
|
||||
public function testFertigGeladeneWallboxSetztVeralteteLeistungAufNull(): void
|
||||
{
|
||||
$status = EaseeLadestatus::ausBeobachtungen([
|
||||
'109' => 4,
|
||||
'110' => 30,
|
||||
'120' => 10.294,
|
||||
], 32);
|
||||
|
||||
self::assertTrue($status['FahrzeugGeladen']);
|
||||
self::assertEqualsWithDelta(0.0, $status['Istleistung_W'], 0.01);
|
||||
}
|
||||
|
||||
public function testApiUndKabelgrenzenBegrenzenDenKonfiguriertenStrom(): void
|
||||
{
|
||||
$status = EaseeLadestatus::ausBeobachtungen([
|
||||
@@ -89,6 +116,24 @@ final class EaseeLadestatusTest extends TestCase
|
||||
self::assertSame(13, $status['ApiMaximalstrom_A']);
|
||||
}
|
||||
|
||||
public function testFreigabeUndGrundOhneLadestromKommenAusEaseeBeobachtungen(): void
|
||||
{
|
||||
$status = EaseeLadestatus::ausBeobachtungen([
|
||||
'31' => true,
|
||||
'42' => true,
|
||||
'43' => true,
|
||||
'96' => 55,
|
||||
'102' => true,
|
||||
'109' => 2,
|
||||
], 16);
|
||||
|
||||
self::assertTrue($status['EaseeAktiviert']);
|
||||
self::assertTrue($status['AutorisierungErforderlich']);
|
||||
self::assertTrue($status['RemoteStartErforderlich']);
|
||||
self::assertTrue($status['SmartCharging']);
|
||||
self::assertSame(55, $status['GrundKeinLadestrom']);
|
||||
}
|
||||
|
||||
public function testNurSolarladenIstFestErzwungen(): void
|
||||
{
|
||||
self::assertFalse(EaseeLadestatus::solarladenErzwungen(0));
|
||||
|
||||
@@ -62,10 +62,33 @@ final class EaseeModuleStrukturTest extends TestCase
|
||||
|
||||
self::assertStringContainsString('implements VerbraucherSchnittstelle', $modul);
|
||||
self::assertStringContainsString('EaseeLadestatus::ausBeobachtungen', $modul);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("'FahrzeugVerbunden'", $modul);
|
||||
self::assertStringContainsString("'Phasenzahl'", $modul);
|
||||
self::assertStringNotContainsString('7500', $modul);
|
||||
self::assertStringNotContainsString('Abfrageintervall', $modul);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableFloat('Istleistung', 'Istleistung', '', 170)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 180)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString('RegeluebergangTimeout', $modul);
|
||||
self::assertStringContainsString("'action' => 'StartCharging'", $modul);
|
||||
|
||||
$gateway = (string) file_get_contents(
|
||||
__DIR__ . '/../EaseeGateway/module.php'
|
||||
);
|
||||
self::assertStringContainsString('/commands/start_charging', $gateway);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EinspeiseRegler;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EinspeiseReglerTest extends TestCase
|
||||
{
|
||||
public function testUeberschreitungWirdAufDieGesamtanlageBegrenzt(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-15000.0,
|
||||
10000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 5000, 'Istleistung_W' => 5000],
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame(10000.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
self::assertSame(5000.0, $ergebnis['Abregelbedarf_W']);
|
||||
self::assertSame(6666.667, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(3333.333, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testAbregelungFolgtDerTatsaechlichenErzeugung(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-10000.0,
|
||||
5000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 0],
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame(5000.0, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(0.0, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testFreierNetzspielraumHebtEineBestehendeBegrenzungAn(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-6000.0,
|
||||
10000.0,
|
||||
[
|
||||
['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 4000],
|
||||
['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 2000],
|
||||
],
|
||||
['wr-1' => 5000, 'wr-2' => 5000]
|
||||
);
|
||||
|
||||
self::assertSame(14000.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-1']);
|
||||
self::assertSame(7000.0, $ergebnis['Grenzen_W']['wr-2']);
|
||||
}
|
||||
|
||||
public function testRegelungFolgtAuchKleinenAbweichungenStufenlos(): void
|
||||
{
|
||||
$ergebnis = EinspeiseRegler::verteile(
|
||||
-10050.0,
|
||||
10000.0,
|
||||
[['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 9000]],
|
||||
['wr-1' => 8000]
|
||||
);
|
||||
|
||||
self::assertSame(7950.0, $ergebnis['ZielGesamtleistung_W']);
|
||||
}
|
||||
|
||||
public function testStellwerteWerdenInProzentOderWattAusgegeben(): void
|
||||
{
|
||||
self::assertSame(62.5, EinspeiseRegler::stellwert(5000.0, 8000.0, 'percent'));
|
||||
self::assertSame(5000.0, EinspeiseRegler::stellwert(5000.0, 8000.0, 'watt'));
|
||||
}
|
||||
|
||||
public function testUngueltigeBegrenzungsartWirdAbgelehnt(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
EinspeiseRegler::stellwert(5000.0, 8000.0, 'volt');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\EnergieMessung;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnergieMessungTest extends TestCase
|
||||
{
|
||||
public function testIntegriertVierLeistungenMitVorzeichenrichtung(): void
|
||||
{
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => 7200.0,
|
||||
'Haus' => 3600.0,
|
||||
'Netz' => -1800.0,
|
||||
'Batterie' => 900.0,
|
||||
], 300);
|
||||
|
||||
self::assertEqualsWithDelta(0.6, $werte['PVEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.3, $werte['Hausenergie'], 0.000001);
|
||||
self::assertSame(0.0, $werte['NetzbezugEnergie']);
|
||||
self::assertEqualsWithDelta(0.15, $werte['EinspeisungEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.075, $werte['BatterieLadenEnergie'], 0.000001);
|
||||
self::assertSame(0.0, $werte['BatterieEntladenEnergie']);
|
||||
self::assertSame(0.0, $werte['SDLLadenEnergie']);
|
||||
self::assertSame(0.0, $werte['SDLEntladenEnergie']);
|
||||
}
|
||||
|
||||
public function testTrenntBezugUndBatterieentladung(): void
|
||||
{
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => -10.0,
|
||||
'Haus' => -10.0,
|
||||
'Netz' => 1000.0,
|
||||
'Batterie' => -2000.0,
|
||||
], 3600);
|
||||
|
||||
self::assertSame(0.0, $werte['PVEnergie']);
|
||||
self::assertSame(0.0, $werte['Hausenergie']);
|
||||
self::assertSame(1.0, $werte['NetzbezugEnergie']);
|
||||
self::assertSame(0.0, $werte['EinspeisungEnergie']);
|
||||
self::assertSame(0.0, $werte['BatterieLadenEnergie']);
|
||||
self::assertSame(2.0, $werte['BatterieEntladenEnergie']);
|
||||
}
|
||||
|
||||
public function testBerechnetHausverbrauchAusDerAnlagenbilanz(): void
|
||||
{
|
||||
self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0));
|
||||
self::assertSame(3500.0, EnergieMessung::hausleistung(2000.0, 500.0, -1000.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausleistung(500.0, -1200.0, 0.0));
|
||||
}
|
||||
|
||||
public function testBeruecksichtigtSDLVorDerBegrenzungDerHausleistung(): void
|
||||
{
|
||||
self::assertSame(1700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, 1000.0));
|
||||
self::assertSame(3700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, -1000.0));
|
||||
self::assertSame(1000.0, EnergieMessung::hausleistung(0.0, -1000.0, 0.0, -2000.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausleistung(500.0, 0.0, 0.0, 1000.0));
|
||||
self::assertSame(
|
||||
EnergieMessung::hausleistung(4200.0, -800.0, 700.0),
|
||||
EnergieMessung::hausleistung(4200.0, -800.0, 700.0, 0.0)
|
||||
);
|
||||
}
|
||||
|
||||
public function testBerechnetHausenergieAusDenZaehlerdeltas(): void
|
||||
{
|
||||
self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 5.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
public function testBereinigtHausenergieUmSDLLadungUndEntladung(): void
|
||||
{
|
||||
self::assertSame(5.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 3.0, 0.0));
|
||||
self::assertSame(11.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 0.0, 3.0));
|
||||
self::assertSame(7.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 3.0, 2.0));
|
||||
self::assertSame(1.0, EnergieMessung::hausenergie(0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0));
|
||||
self::assertSame(0.0, EnergieMessung::hausenergie(1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0));
|
||||
self::assertSame(
|
||||
EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0),
|
||||
EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, 0.0, 0.0)
|
||||
);
|
||||
}
|
||||
|
||||
public function testIntegriertSDLGetrenntVonBatterieUndHaus(): void
|
||||
{
|
||||
$leistungen = [
|
||||
'PV' => 7200.0,
|
||||
'Haus' => 3600.0,
|
||||
'Netz' => -1800.0,
|
||||
'Batterie' => 900.0,
|
||||
'SDL' => 1800.0,
|
||||
];
|
||||
$laden = EnergieMessung::energieDeltas($leistungen, 300);
|
||||
self::assertEqualsWithDelta(0.15, $laden['SDLLadenEnergie'], 0.000001);
|
||||
self::assertSame(0.0, $laden['SDLEntladenEnergie']);
|
||||
self::assertEqualsWithDelta(0.075, $laden['BatterieLadenEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.3, $laden['Hausenergie'], 0.000001);
|
||||
|
||||
$leistungen['SDL'] = -1800.0;
|
||||
$entladen = EnergieMessung::energieDeltas($leistungen, 300);
|
||||
self::assertSame(0.0, $entladen['SDLLadenEnergie']);
|
||||
self::assertEqualsWithDelta(0.15, $entladen['SDLEntladenEnergie'], 0.000001);
|
||||
self::assertSame($laden['Hausenergie'], $entladen['Hausenergie']);
|
||||
self::assertSame($laden['BatterieLadenEnergie'], $entladen['BatterieLadenEnergie']);
|
||||
|
||||
$leistungen['SDL'] = 0.0;
|
||||
$mitNull = EnergieMessung::energieDeltas($leistungen, 300);
|
||||
unset($leistungen['SDL']);
|
||||
self::assertSame($mitNull, EnergieMessung::energieDeltas($leistungen, 300));
|
||||
}
|
||||
|
||||
public function testIgnoriertNichtEndlicheSDLWerte(): void
|
||||
{
|
||||
foreach ([INF, -INF, NAN] as $ungueltig) {
|
||||
self::assertSame(2700.0, EnergieMessung::hausleistung(4200.0, -800.0, 700.0, $ungueltig));
|
||||
self::assertSame(8.5, EnergieMessung::hausenergie(10.0, 2.0, 4.0, 1.5, 2.0, $ungueltig, $ungueltig));
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => 0.0,
|
||||
'Haus' => 1000.0,
|
||||
'Netz' => 1000.0,
|
||||
'Batterie' => 0.0,
|
||||
'SDL' => $ungueltig,
|
||||
], 3600);
|
||||
|
||||
self::assertSame(0.0, $werte['SDLLadenEnergie']);
|
||||
self::assertSame(0.0, $werte['SDLEntladenEnergie']);
|
||||
self::assertSame(1.0, $werte['Hausenergie']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testIgnoriertUngueltigeDauerUndNichtEndlicheWerte(): void
|
||||
{
|
||||
foreach ([-1, 0] as $dauer) {
|
||||
foreach ([-1000.0, 1000.0] as $sdl) {
|
||||
$werte = EnergieMessung::energieDeltas([
|
||||
'PV' => INF,
|
||||
'Haus' => NAN,
|
||||
'Netz' => 1000.0,
|
||||
'Batterie' => -1000.0,
|
||||
'SDL' => $sdl,
|
||||
], $dauer);
|
||||
|
||||
foreach ($werte as $wert) {
|
||||
self::assertSame(0.0, $wert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ final class LadestationAdapterTest extends TestCase
|
||||
/**
|
||||
* @dataProvider statusFaelle
|
||||
* @param array<string, string> $konfiguration
|
||||
* @param array{Rohstatus: int, Istleistung_W: float} $erwartet
|
||||
* @param array{Rohstatus: int, Istleistung_W: float, Sollstrom_A?: int, Maximalstrom_A?: int, Iststrom_A?: float, Phasenzahl?: int} $erwartet
|
||||
*/
|
||||
public function testStatusaufrufUndDekodierungFuerAlleGeraete(
|
||||
int $geraetetyp,
|
||||
@@ -47,16 +47,30 @@ final class LadestationAdapterTest extends TestCase
|
||||
yield 'go-e alt' => [
|
||||
LadestationAdapter::GO_E_ALT,
|
||||
$this->lokaleKonfiguration(),
|
||||
$this->goEAntwort(2, 1100),
|
||||
$this->goEAntwort(2, 1100, [160, 158, 159]),
|
||||
'http://192.0.2.10/mqtt?payload=',
|
||||
['Rohstatus' => 2, 'Istleistung_W' => 11000.0],
|
||||
[
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 11000.0,
|
||||
'Sollstrom_A' => 16,
|
||||
'Maximalstrom_A' => 16,
|
||||
'Iststrom_A' => 16.0,
|
||||
'Phasenzahl' => 3,
|
||||
],
|
||||
];
|
||||
yield 'go-e Gemini' => [
|
||||
LadestationAdapter::GO_E_GEMINI,
|
||||
$this->lokaleKonfiguration(),
|
||||
$this->goEAntwort(2, 2300),
|
||||
$this->goEAntwort(2, 2300, [9.8, 0, 0]),
|
||||
'http://192.0.2.10/api/status',
|
||||
['Rohstatus' => 2, 'Istleistung_W' => 2300.0],
|
||||
[
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 2300.0,
|
||||
'Sollstrom_A' => 16,
|
||||
'Maximalstrom_A' => 16,
|
||||
'Iststrom_A' => 9.8,
|
||||
'Phasenzahl' => 1,
|
||||
],
|
||||
];
|
||||
yield 'Smart-me Pico' => [
|
||||
LadestationAdapter::SMART_ME_PICO,
|
||||
@@ -71,6 +85,20 @@ final class LadestationAdapterTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function testGoEAusFreigabeWirdAlsNullSollstromDekodiert(): void
|
||||
{
|
||||
$daten = json_decode($this->goEAntwort(2, 0, [0, 0, 0]), true, 512, JSON_THROW_ON_ERROR);
|
||||
$daten['alw'] = false;
|
||||
$daten['frc'] = 1;
|
||||
|
||||
$status = LadestationAdapter::dekodiereStatus(
|
||||
LadestationAdapter::GO_E_GEMINI,
|
||||
json_encode($daten, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
self::assertSame(0, $status['Sollstrom_A']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider steuerFaelle
|
||||
* @param array<string, string> $konfiguration
|
||||
@@ -161,11 +189,22 @@ final class LadestationAdapterTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
private function goEAntwort(int $status, float $leistung): string
|
||||
/** @param list<int|float> $stroeme */
|
||||
private function goEAntwort(int $status, float $leistung, array $stroeme): string
|
||||
{
|
||||
$nrg = array_fill(0, 12, 0);
|
||||
foreach (array_values($stroeme) as $index => $strom) {
|
||||
$nrg[4 + $index] = $strom;
|
||||
}
|
||||
$nrg[11] = $leistung;
|
||||
|
||||
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
|
||||
return json_encode([
|
||||
'car' => $status,
|
||||
'nrg' => $nrg,
|
||||
'amp' => 16,
|
||||
'ama' => 16,
|
||||
'alw' => true,
|
||||
'frc' => 2,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,24 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LadestationReglerTest extends TestCase
|
||||
{
|
||||
public function testPhasenprobeUnterscheidetBeiMindeststromOhneMaximalstromsprung(): void
|
||||
{
|
||||
self::assertSame(
|
||||
LadestationRegler::PHASEN_EIN,
|
||||
LadestationRegler::erkennePhasenzahlAusLeistung(1380.0, 6)
|
||||
);
|
||||
self::assertSame(
|
||||
LadestationRegler::PHASEN_DREI,
|
||||
LadestationRegler::erkennePhasenzahlAusLeistung(4104.0, 6)
|
||||
);
|
||||
}
|
||||
|
||||
public function testPhasenprobeWeistUngueltigenErkennungsstromAb(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
LadestationRegler::erkennePhasenzahlAusLeistung(4104.0, 5);
|
||||
}
|
||||
|
||||
public function testFahrzeugUndDreiPhasenWerdenWieInEnelixEinsErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
@@ -22,28 +40,111 @@ final class LadestationReglerTest extends TestCase
|
||||
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
|
||||
}
|
||||
|
||||
public function testEinphasigesFahrzeugWirdUnterhalb7500WErkannt(): void
|
||||
public function testDirektGemeldeteEinphasigkeitWirdAuchUnter7500WErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 2300.0,
|
||||
'Phasenzahl' => 1,
|
||||
], false);
|
||||
|
||||
self::assertSame(LadestationRegler::PHASEN_EIN, $status['Phasenzahl']);
|
||||
self::assertEqualsWithDelta(10.0, $status['Ladestrom_A'], 0.01);
|
||||
}
|
||||
|
||||
public function testGeladenWirdNachVerbindungUnterSechsAmpereErkannt(): void
|
||||
public function testErkannteDreiphasigkeitBleibtWaehrendLadepauseErhalten(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true);
|
||||
], true, false, false, true, LadestationRegler::PHASEN_DREI);
|
||||
|
||||
self::assertSame(LadestationRegler::PHASEN_DREI, $status['Phasenzahl']);
|
||||
self::assertSame(0.0, $status['Ladestrom_A']);
|
||||
}
|
||||
|
||||
public function testPhasenBleibenOhneMessungBisZurErkennungUnbekannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], false);
|
||||
|
||||
self::assertSame(LadestationRegler::PHASEN_UNBEKANNT, $status['Phasenzahl']);
|
||||
self::assertSame([0], LadestationRegler::leistungsangebot(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
LadestationRegler::PHASEN_UNBEKANNT,
|
||||
16,
|
||||
true,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
public function testAnlaufOhneGemesseneLadeleistungGiltNichtAlsLadeende(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, false, true, false);
|
||||
|
||||
self::assertFalse($status['FahrzeugGeladen']);
|
||||
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testGeladenWirdErstNachZuvorGemessenerLadeleistungErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, false, true, true);
|
||||
|
||||
self::assertTrue($status['FahrzeugGeladen']);
|
||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testAbsichtlichGestoppteLadungWirdNichtAlsGeladenErkannt(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, false, false);
|
||||
|
||||
self::assertFalse($status['FahrzeugGeladen']);
|
||||
self::assertSame(2, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testErkanntesLadeendeBleibtBisZumAbsteckenErhalten(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 0.0,
|
||||
], true, true, false);
|
||||
|
||||
self::assertTrue($status['FahrzeugGeladen']);
|
||||
self::assertSame(4, $status['Fahrzeugstatus']);
|
||||
}
|
||||
|
||||
public function testDirekterPhasenstromHatVorrangVorNominalerLeistungsumrechnung(): void
|
||||
{
|
||||
$status = LadestationRegler::normalisiereStatus([
|
||||
'Rohstatus' => 2,
|
||||
'Istleistung_W' => 10263.0,
|
||||
'Iststrom_A' => 15.9,
|
||||
], true);
|
||||
|
||||
self::assertEqualsWithDelta(15.9, $status['Ladestrom_A'], 0.01);
|
||||
}
|
||||
|
||||
public function testFahrzeugMaximalstromWirdMitZweiAmpereAbstandErkannt(): void
|
||||
{
|
||||
self::assertSame(18, LadestationRegler::ermittleFahrzeugMaximalstrom(20, 15.9, 20));
|
||||
self::assertNull(LadestationRegler::ermittleFahrzeugMaximalstrom(17, 15.9, 20));
|
||||
self::assertNull(LadestationRegler::ermittleFahrzeugMaximalstrom(20, 0.0, 20));
|
||||
}
|
||||
|
||||
public function testBetriebsartUndSolarladenBestimmenDasLeistungsangebot(): void
|
||||
{
|
||||
$solarPV = LadestationRegler::leistungsangebot(true, true, true, false, 1, 16, true, false);
|
||||
@@ -53,7 +154,7 @@ final class LadestationReglerTest extends TestCase
|
||||
|
||||
self::assertSame(0, $solarPV[0]);
|
||||
self::assertCount(12, $solarPV);
|
||||
self::assertSame([3850], $normalPV);
|
||||
self::assertSame([3680], $normalPV);
|
||||
self::assertSame([0], $solarPeak);
|
||||
self::assertSame($solarPV, $normalPeak);
|
||||
}
|
||||
@@ -61,7 +162,7 @@ final class LadestationReglerTest extends TestCase
|
||||
public function testPeakKannJedesFahrzeugAufNullSetzen(): void
|
||||
{
|
||||
self::assertSame(0, LadestationRegler::stromFuerLeistung(0, 3));
|
||||
self::assertSame(6, LadestationRegler::stromFuerLeistung(4150, 3));
|
||||
self::assertSame(16, LadestationRegler::stromFuerLeistung(11000, 3));
|
||||
self::assertSame(6, LadestationRegler::stromFuerLeistung(4104, 3));
|
||||
self::assertSame(16, LadestationRegler::stromFuerLeistung(10944, 3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,72 @@ final class LadestationStandAloneModulstrukturTest extends TestCase
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyBoolean('Ladefreigabe', true)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('Phasenerkennungszeit', 60)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('ZeitZwischenZustandswechseln', 1)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('Mindesteinschaltdauer', 0)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyInteger('Mindestausschaltdauer', 0)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("EnableAction('Ladefreigabe')", $modul);
|
||||
self::assertStringContainsString("EnableAction('Solarladen')", $modul);
|
||||
self::assertStringContainsString("ReadPropertyBoolean('LoggingEin')", $modul);
|
||||
self::assertStringContainsString("ReadPropertyBoolean('EinstellungenInVisu')", $modul);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableInteger('Fahrzeugstatus', 'Fahrzeugstatus', '', 200)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableFloat('Istleistung', 'Istleistung', '', 170)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterVariableInteger('Sollleistung', 'Sollleistung', '', 180)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString('RegeluebergangTimeout', $modul);
|
||||
self::assertStringContainsString('PHASENERKENNUNGSSTROM = 6', $modul);
|
||||
self::assertStringContainsString('UnregisterVariable($ident)', $modul);
|
||||
}
|
||||
|
||||
public function testMeldezyklusSendetDenBereitsGelesenenZustandOhneZusaetzlichenHttpAbruf(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
self::assertSame(1, preg_match(
|
||||
"/case 'Melden':(?<block>.*?)case 'ManagerdatenEmpfangen':/s",
|
||||
$modul,
|
||||
$treffer
|
||||
));
|
||||
self::assertStringContainsString('$this->sendeVerbraucherdaten();', $treffer['block']);
|
||||
self::assertStringNotContainsString('$this->aktualisiere(', $treffer['block']);
|
||||
}
|
||||
|
||||
public function testVomGeraetBestaetigterSollstromWirdFuerWiederholungVerwendet(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
self::assertStringContainsString("\$rohstatus['Sollstrom_A']", $modul);
|
||||
self::assertStringContainsString(
|
||||
"WriteAttributeInteger('LetzterGesetzterStrom', \$geraeteSollstrom)",
|
||||
$modul
|
||||
);
|
||||
self::assertStringContainsString("RegisterAttributeInteger('GeraeteMaximalstrom', 32)", $modul);
|
||||
self::assertStringContainsString(
|
||||
"WriteAttributeInteger('GeraeteMaximalstrom', (int) \$rohstatus['Maximalstrom_A'])",
|
||||
$modul
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ final class LibraryMetadataTest extends TestCase
|
||||
self::assertSame('Enelix EMS', $daten['name']);
|
||||
self::assertSame('Enelix', $daten['author']);
|
||||
self::assertSame('8.0', $daten['compatibility']['version']);
|
||||
self::assertSame('0.1', $daten['version']);
|
||||
self::assertSame(1, $daten['build']);
|
||||
self::assertGreaterThan(0, $daten['date']);
|
||||
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $daten['id']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,171 @@ final class LizenzpruefungTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testZweiLizenzierteBoilerWerdenFreigegeben(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'sku' => 'ENX-BOILER-MULTI',
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 2,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 2]);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testDritterBoilerBeiZweiLizenzenWirdAbgelehnt(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'sku' => 'ENX-BOILER-MULTI',
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 2,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
'Lizenzkontingent fuer boiler_multi ueberschritten: 3 verwendet, 2 lizenziert.'
|
||||
);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, ['boiler_multi' => 3]);
|
||||
}
|
||||
|
||||
public function testVerbraucherkontingenteWerdenJeTypSummiertUndGetrenntGeprueft(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'] = array_merge($lease['entitlements'], [
|
||||
[
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'boiler_multi',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'battery',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
],
|
||||
[
|
||||
'catalog_key' => 'battery',
|
||||
'quantity' => 10,
|
||||
'status' => 'revoked',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(2, Lizenzpruefung::berechtigungsMenge($lease, 'boiler_multi'));
|
||||
self::assertSame(1, Lizenzpruefung::berechtigungsMenge($lease, 'battery'));
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, [
|
||||
'boiler_multi' => 2,
|
||||
'battery' => 1,
|
||||
]);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testSeparateWaermepumpenlizenzBleibtUnveraendert(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'heat_pump',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$verwendung = ['heat_pump' => 1];
|
||||
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
$verwendung
|
||||
);
|
||||
|
||||
self::assertSame($verwendung, $pruefmengen);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testFreieBestandslizenzDecktEineWaermepumpeAb(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'consumer_single',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
['heat_pump' => 1]
|
||||
);
|
||||
|
||||
self::assertSame(['heat_pump' => 0, 'consumer_single' => 1], $pruefmengen);
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testBestandslizenzDarfNichtDoppeltVerwendetWerden(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => 'consumer_single',
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$pruefmengen = Lizenzpruefung::beruecksichtigeBestandslizenzFuerWaermepumpen(
|
||||
$lease,
|
||||
['consumer_single' => 1, 'heat_pump' => 1]
|
||||
);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Lizenzkontingent fuer heat_pump ueberschritten');
|
||||
Lizenzpruefung::pruefeVerbrauchermengen($lease, $pruefmengen);
|
||||
}
|
||||
|
||||
public function testPvUndVerbrauchGebenPrognoseFrei(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_VERBRAUCH,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testEinzelnePrognoseBerechtigungReichtNichtAus(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::PROGNOSE_PV,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertFalse(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testNetzfahrplanGibtPrognoseAlleineFrei(): void
|
||||
{
|
||||
$lease = $this->lease('manager_standard');
|
||||
$lease['entitlements'][] = [
|
||||
'catalog_key' => Lizenzpruefung::NETZFAHRPLAN,
|
||||
'quantity' => 1,
|
||||
'status' => 'active',
|
||||
];
|
||||
|
||||
self::assertTrue(Lizenzpruefung::hatPrognoseBerechtigung($lease));
|
||||
}
|
||||
|
||||
public function testLizenzcodeWirdWieAufDemServerNormalisiert(): void
|
||||
{
|
||||
self::assertSame(
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ManagerEnergieModulstrukturTest extends TestCase
|
||||
{
|
||||
public function testManagerKonfiguriertMessquellenUndArchivregeln(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
|
||||
$formular = (string) file_get_contents(__DIR__ . '/../Manager/form.json');
|
||||
|
||||
foreach ([
|
||||
"RegisterPropertyBoolean('EnergyPieAnzeigen', false)",
|
||||
"RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false)",
|
||||
"RegisterPropertyInteger('DiagrammPVModus', 0)",
|
||||
"RegisterPropertyInteger('DiagrammBatterieModus', 0)",
|
||||
"RegisterPropertyBoolean('LadestationenSeparatAnzeigen', true)",
|
||||
"RegisterPropertyBoolean('VerbraucherSeparatAnzeigen', true)",
|
||||
"RegisterAttributeBoolean('EnergieflussNeuErzeugen', false)",
|
||||
"RegisterPropertyBoolean('FunFactsAnzeigen', false)",
|
||||
"RegisterPropertyBoolean('EnergieflussAnzeigen', false)",
|
||||
"RegisterPropertyInteger('EnergieflussLeistungseinheit', 0)",
|
||||
"RegisterPropertyInteger('LeistungsaufzeichnungMinuten', 1)",
|
||||
"RegisterPropertyInteger('LeistungLoeschenMonate', 12)",
|
||||
"RegisterPropertyInteger('EnergieaufzeichnungMinuten', 5)",
|
||||
"RegisterPropertyInteger('EnergieVerdichtenMonate', 12)",
|
||||
"RegisterPropertyInteger('EnergieLoeschenMonate', 0)",
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $modul);
|
||||
}
|
||||
foreach ([
|
||||
'AC_SetLoggingStatus',
|
||||
'AC_SetAggregationType',
|
||||
'AC_SetCompaction',
|
||||
'AC_ReAggregateVariable',
|
||||
'IPS_CreateMedia(4)',
|
||||
'IPS_SetMediaContent',
|
||||
'ENERGY_PIE_MODULE_ID',
|
||||
'ENERGY_FLOW_MODULE_ID',
|
||||
'energieflussKnoten',
|
||||
"'type' => \$typ",
|
||||
"'bar'",
|
||||
'EnergiediagrammKonfiguration',
|
||||
'EnergieflussKonfiguration',
|
||||
'IPS_DeleteInstance',
|
||||
'IPS_DeleteMedia',
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $modul . $trait);
|
||||
}
|
||||
foreach ([
|
||||
'Energieaufzeichnung',
|
||||
'EnergieflussLeistungseinheit',
|
||||
'DiagrammPVModus',
|
||||
'DiagrammBatterieModus',
|
||||
'LadestationenSeparatAnzeigen',
|
||||
'VerbraucherSeparatAnzeigen',
|
||||
'Bestehende Energy Pies, Diagramme und Energiefluesse bleiben bei Apply unveraendert',
|
||||
'Wechselrichter / AC-Wandler',
|
||||
'keine doppelte Batteriekonfiguration',
|
||||
'MesswerteAnzeigen',
|
||||
'IstleistungVariableID',
|
||||
'LeistungVariableID',
|
||||
'SOCVariableID',
|
||||
'NetzbezugEnergieVariableID',
|
||||
'NetzeinspeisungEnergieVariableID',
|
||||
'ErzeugungsenergieVariableID',
|
||||
'LadeenergieVariableID',
|
||||
'EntladeenergieVariableID',
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $formular);
|
||||
}
|
||||
}
|
||||
|
||||
public function testManagerFuehrtAlleMessvariablenUndRichtungszaehler(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
foreach ([
|
||||
'PVLeistungArchiv',
|
||||
'HausverbrauchLeistungArchiv',
|
||||
'NetzleistungArchiv',
|
||||
'BatterieleistungArchiv',
|
||||
'PVEnergie',
|
||||
'Hausenergie',
|
||||
'NetzbezugEnergie',
|
||||
'EinspeisungEnergie',
|
||||
'BatterieLadenEnergie',
|
||||
'BatterieEntladenEnergie',
|
||||
] as $ident) {
|
||||
self::assertStringContainsString("'" . $ident . "'", $modul);
|
||||
}
|
||||
self::assertStringContainsString("RegisterTimer(
|
||||
'MesswerteAufzeichnen'", $modul);
|
||||
self::assertStringContainsString('EnergieMessung::energieDeltas', $modul);
|
||||
self::assertStringContainsString('EnergieMessung::hausenergie', $modul);
|
||||
self::assertStringContainsString("RegisterAttributeString('MessungZaehlerstaende'", $modul);
|
||||
}
|
||||
|
||||
public function testFunFactsSindResponsivUndErklaerenUmrechnungen(): void
|
||||
{
|
||||
$trait = (string) file_get_contents(__DIR__ . '/../libs/ManagerEnergieTrait.php');
|
||||
|
||||
self::assertStringContainsString('grid-template-columns:repeat(3,minmax(0,1fr))', $trait);
|
||||
self::assertStringContainsString('@media(max-width:820px)', $trait);
|
||||
self::assertStringContainsString('10 kWh pro Liter Heizoel', $trait);
|
||||
self::assertStringContainsString('2,4 Flugkilometern', $trait);
|
||||
self::assertStringContainsString('AC_GetAggregatedValues', $trait);
|
||||
}
|
||||
}
|
||||
@@ -48,18 +48,55 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
'VerbraucherTimeout',
|
||||
'MesswertMaxAlter',
|
||||
'Umschaltdifferenz',
|
||||
'EinspeisebegrenzungAktiv',
|
||||
'Einspeisegrenze',
|
||||
'PrognoseAktiv',
|
||||
'SDLAktiv',
|
||||
'SDLLeistungVariableID',
|
||||
'SDLLeistungsfaktor',
|
||||
'SDLSOCVariableID',
|
||||
'SDLEnergieflussAnzeigen',
|
||||
'SDLDiagrammeAnzeigen',
|
||||
'PrognoseLizenzInformation',
|
||||
'NetzfahrplanAktiv',
|
||||
'NetzbezugEnergieVariableID',
|
||||
'NetzeinspeisungEnergieVariableID',
|
||||
'IstleistungVariableID',
|
||||
'ErzeugungsenergieVariableID',
|
||||
'Erzeugungsenergiefaktor',
|
||||
'BegrenzungVariableID',
|
||||
'Begrenzungsart',
|
||||
'DiagnosevariablenAnzeigen',
|
||||
'LoggingEin',
|
||||
'Lizenzcode',
|
||||
'LizenzPruefen',
|
||||
'LizenzVerwalten',
|
||||
'LizenzInstallationID',
|
||||
'LizenzInformation',
|
||||
'VerbraucherAktualisieren',
|
||||
'AnlagenWechselrichter',
|
||||
'AnlagenPVFlaechen',
|
||||
'AnlagenBatterien',
|
||||
'Istleistungsfaktor',
|
||||
'LeistungVariableID',
|
||||
'Leistungsfaktor',
|
||||
'SOCVariableID',
|
||||
'LadeenergieVariableID',
|
||||
'EntladeenergieVariableID',
|
||||
'Energiefaktor',
|
||||
'EnergyPieAnzeigen',
|
||||
'EnergiediagrammeAnzeigen',
|
||||
'FunFactsAnzeigen',
|
||||
'EnergieflussAnzeigen',
|
||||
'PrognoseSendeintervall',
|
||||
'StoerueberwachungAktiv',
|
||||
'StoerungsSendeintervall',
|
||||
] as $property) {
|
||||
self::assertStringContainsString($property, $json);
|
||||
}
|
||||
foreach (['Regelintervall', 'Vorgabeintervall', 'UmschaltschwelleUeberschuss',
|
||||
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss'] as $entfernt
|
||||
'UmschaltschwelleLastspitze', 'Optionale obere Anschluesse', 'LizenzAnschluss',
|
||||
'Einspeisetoleranz', 'ACEinspeisegrenze_kW', 'ACBezugsgrenze_kW'] as $entfernt
|
||||
) {
|
||||
self::assertStringNotContainsString($entfernt, $json);
|
||||
}
|
||||
@@ -69,6 +106,8 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
self::assertStringContainsString($bereich, $json);
|
||||
}
|
||||
self::assertSame('Lizenzierung', $formular['elements'][0]['caption']);
|
||||
self::assertStringContainsString('https://license.enelix.ch', $json);
|
||||
self::assertStringContainsString('"link":true', $json);
|
||||
self::assertStringContainsString(
|
||||
"'SuchbereichID' => \$SuchbereichID",
|
||||
$json
|
||||
@@ -107,13 +146,219 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString("RegisterAttributeString('LizenzInstallationID'", $inhalt);
|
||||
self::assertSame(
|
||||
1,
|
||||
preg_match('/public function Create\(\): void.*?public function ApplyChanges\(\): void/s', $inhalt, $treffer)
|
||||
);
|
||||
self::assertStringNotContainsString(
|
||||
"WriteAttributeString('LizenzInstallationID'",
|
||||
$treffer[0]
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"parent::ApplyChanges();\n\n \$this->initialisiereLizenzInstallationID();",
|
||||
$inhalt
|
||||
);
|
||||
self::assertSame(1, substr_count(
|
||||
$inhalt,
|
||||
"WriteAttributeString('LizenzInstallationID'"
|
||||
));
|
||||
self::assertStringContainsString("RegisterAttributeString('LizenzLease'", $inhalt);
|
||||
self::assertStringContainsString("RegisterAttributeString('LizenzcodeHash'", $inhalt);
|
||||
self::assertStringContainsString('STATUS_LIZENZ_UNGUELTIG', $inhalt);
|
||||
self::assertStringContainsString('aktualisiereLizenzfreigabe(', $inhalt);
|
||||
self::assertStringContainsString('pruefeVerbraucherlizenzen(', $inhalt);
|
||||
self::assertStringContainsString('lizenzBerechtigungenPassen(', $inhalt);
|
||||
self::assertStringContainsString('peakShavingLizenziert(', $inhalt);
|
||||
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz fehlt)', $inhalt);
|
||||
self::assertStringContainsString('Aus (Peak-Shaving-Lizenz verfuegbar)', $inhalt);
|
||||
self::assertStringContainsString(
|
||||
'Konstant und Monatlich benoetigen eine Peak-Shaving-Lizenz.',
|
||||
$inhalt
|
||||
);
|
||||
foreach (['battery', 'buffer_storage', 'ev_charger', 'consumer_single', 'heat_pump', 'boiler_multi'] as $katalog) {
|
||||
self::assertStringContainsString("'Katalog' => '" . $katalog . "'", $inhalt);
|
||||
}
|
||||
foreach ([
|
||||
'{B7C54AF4-AD7D-4FE4-B75D-203693906251}',
|
||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||
'{A31C9274-54F7-4BD2-9804-7AF23E86C4D1}',
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'{32F65988-BE55-4FD6-9FFF-CF1C4C8628F8}',
|
||||
'{C92D5EEF-9632-47A5-9659-4B02BF40FBE9}',
|
||||
'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}',
|
||||
] as $modulID) {
|
||||
self::assertStringContainsString("'" . $modulID . "' =>", $inhalt);
|
||||
}
|
||||
self::assertStringContainsString("'Name' => 'Pufferspeicher'", $inhalt);
|
||||
self::assertStringContainsString("'Name' => 'Batteriespeicher'", $inhalt);
|
||||
self::assertStringContainsString(
|
||||
'Bitte Auswahl reduzieren oder Lizenz erweitern.',
|
||||
$inhalt
|
||||
);
|
||||
foreach ([
|
||||
"'NichtGeregelt'",
|
||||
"'NichtGeregelteGeraete'",
|
||||
"'InstanzStatus'",
|
||||
"'LetzteMeldung'",
|
||||
"'GemeldeteBetriebsart'",
|
||||
"'Verfuegbar'",
|
||||
] as $detailfeld) {
|
||||
self::assertStringContainsString($detailfeld, $inhalt);
|
||||
}
|
||||
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
|
||||
}
|
||||
|
||||
public function testManagerSynchronisiertDieTopologieMitSeparatemGeraetezugang(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
self::assertStringContainsString(
|
||||
'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology',
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterAttributeString('PrognoseInstallationsToken', '')",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString('JSON_PRESERVE_ZERO_FRACTION', $inhalt);
|
||||
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
|
||||
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
|
||||
self::assertStringContainsString(
|
||||
"|| \$this->ReadPropertyBoolean('StoerueberwachungAktiv')",
|
||||
$inhalt
|
||||
);
|
||||
self::assertGreaterThanOrEqual(
|
||||
3,
|
||||
substr_count($inhalt, '$this->geraetezugangBenoetigt()'),
|
||||
'Der Geraetezugang muss beim Anwenden, Erneuern und Anfordern beruecksichtigt werden.'
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"\$this->ReadPropertyString('Lizenzcode'),\n \$geraetezugangFehlt",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
|
||||
self::assertStringContainsString('private function geraetezugangBenoetigt(): bool', $inhalt);
|
||||
self::assertStringContainsString(
|
||||
"if (\$this->aktualisiereLizenzfreigabe(\$wert, true)) {",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringNotContainsString(
|
||||
"if (!\$this->ReadPropertyBoolean('PrognoseAktiv')) {\n \$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');\n return;\n }\n\n try {",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry',
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString("'PrognoseSenden',", $inhalt);
|
||||
self::assertStringContainsString("'capturedAt' => gmdate(", $inhalt);
|
||||
foreach (["'PV'", "'Hausverbrauch'", "'Netzleistung'", "'SOC'"] as $messwert) {
|
||||
self::assertStringContainsString($messwert, $inhalt);
|
||||
}
|
||||
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
|
||||
self::assertStringNotContainsString(
|
||||
"protokolliere('Prognosesynchronisation', \$token)",
|
||||
$inhalt
|
||||
);
|
||||
|
||||
$leasePruefung = strpos($inhalt, 'Lizenzpruefung::pruefeLease(');
|
||||
$tokenSpeicherung = strpos(
|
||||
$inhalt,
|
||||
"WriteAttributeString('PrognoseInstallationsToken', \$geraeteToken)"
|
||||
);
|
||||
self::assertNotFalse($leasePruefung);
|
||||
self::assertNotFalse($tokenSpeicherung);
|
||||
self::assertTrue(
|
||||
$leasePruefung < $tokenSpeicherung,
|
||||
'Der Gerätezugang darf erst nach validierter Lizenzantwort gespeichert werden.'
|
||||
);
|
||||
}
|
||||
|
||||
public function testManagerRegeltAktuelleVerbraucherTrotzFehlenderRueckmeldungen(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
self::assertStringNotContainsString("if (!\$synchronisiert) {", $inhalt);
|
||||
self::assertStringContainsString(
|
||||
"'Synchronisiert' => \$synchronisiert",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"'NichtGeregelt' => \$nichtGeregelt",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"\$ergebnis = ManagerRegler::verteile(",
|
||||
$inhalt
|
||||
);
|
||||
}
|
||||
|
||||
public function testManagerUebertraegtStoerungsvollsnapshotsMitRetry(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
self::assertFileExists(__DIR__ . '/../libs/StoerungsSnapshot.php');
|
||||
self::assertStringContainsString(
|
||||
'https://license.enelix.ch/api/v1/installations/%s/faults',
|
||||
$inhalt
|
||||
);
|
||||
foreach ([
|
||||
"RegisterPropertyBoolean('StoerueberwachungAktiv', false)",
|
||||
"RegisterPropertyInteger('StoerungsSendeintervall', 300)",
|
||||
"RegisterAttributeString('StoerungsSnapshot', '{}')",
|
||||
"RegisterAttributeString('StoerungsGesendeterHash', '')",
|
||||
"RegisterAttributeInteger('StoerungsNaechsterVersuch', 0)",
|
||||
"'StoerungenSenden',",
|
||||
'StoerungsSnapshot::erstellen(',
|
||||
'StoerungsSnapshot::zustandHash(',
|
||||
"CURLOPT_CUSTOMREQUEST => 'PUT'",
|
||||
] as $erwartet) {
|
||||
self::assertStringContainsString($erwartet, $inhalt);
|
||||
}
|
||||
self::assertStringNotContainsString(
|
||||
'"name":"StoermeldeAnschluss"',
|
||||
str_replace(' ', '', (string) file_get_contents(__DIR__ . '/../Manager/form.json'))
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"if (\$token === '') {\n"
|
||||
. " \$this->aktualisiereLizenzfreigabe(\n"
|
||||
. " \$this->ReadPropertyString('Lizenzcode'),\n"
|
||||
. " true",
|
||||
$inhalt
|
||||
);
|
||||
self::assertGreaterThanOrEqual(
|
||||
3,
|
||||
substr_count($inhalt, '$this->geraetezugangBenoetigt()'),
|
||||
'Der Geraetezugang muss beim Anwenden, Erneuern und Anfordern beruecksichtigt werden.'
|
||||
);
|
||||
}
|
||||
|
||||
public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
|
||||
self::assertFileExists(__DIR__ . '/../libs/EinspeiseRegler.php');
|
||||
self::assertStringContainsString('EinspeiseRegler::verteile(', $inhalt);
|
||||
self::assertStringContainsString('EinspeiseRegler::stellwert(', $inhalt);
|
||||
self::assertStringContainsString("RegisterAttributeString('LetztePvGrenzen'", $inhalt);
|
||||
self::assertStringContainsString("RegisterAttributeString('Netzfahrplan'", $inhalt);
|
||||
self::assertStringContainsString(
|
||||
'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule',
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString('Lizenzpruefung::NETZFAHRPLAN', $inhalt);
|
||||
self::assertStringContainsString('Lizenzpruefung::PROGNOSE_PV', $inhalt);
|
||||
self::assertStringContainsString('Lizenzpruefung::PROGNOSE_VERBRAUCH', $inhalt);
|
||||
self::assertStringContainsString('prognoseLizenzStatustext(', $inhalt);
|
||||
self::assertStringContainsString(
|
||||
"foreach (['PeakShavingTitel', 'EinspeisebegrenzungAktiv', 'Einspeisegrenze']",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt);
|
||||
self::assertStringContainsString('gibEinspeisebegrenzungFrei()', $inhalt);
|
||||
self::assertStringNotContainsString("RegisterPropertyFloat('Einspeisetoleranz'", $inhalt);
|
||||
}
|
||||
|
||||
public function testManagerVerwendetDieNeuenZeitdefaults(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
@@ -136,7 +381,30 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
|
||||
self::assertStringContainsString("require_once __DIR__ . '/../libs/Anlagentopologie.php'", $inhalt);
|
||||
self::assertStringContainsString('public function AnlagentopologieExportieren(): string', $inhalt);
|
||||
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
|
||||
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
|
||||
}
|
||||
|
||||
public function testVerbrauchermeldungPlantRegelungOhneSynchronenRueckruf(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
|
||||
self::assertStringContainsString(
|
||||
"RegisterTimer(\n 'RegelungVerzoegert'",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString("case 'RegelnVerzoegert':", $inhalt);
|
||||
self::assertSame(1, preg_match(
|
||||
'/public function VerbraucherdatenEmpfangen\(array \$daten\): void(?<block>.*?)'
|
||||
. 'private function aktualisiereVerbraucherEnergie/s',
|
||||
$inhalt,
|
||||
$treffer
|
||||
));
|
||||
self::assertStringContainsString(
|
||||
"SetTimerInterval('RegelungVerzoegert', 100)",
|
||||
$treffer['block']
|
||||
);
|
||||
self::assertStringNotContainsString('$this->regeln(false);', $treffer['block']);
|
||||
}
|
||||
}
|
||||
|
||||
+440
-4
@@ -11,6 +11,59 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ManagerReglerTest extends TestCase
|
||||
{
|
||||
public function testFahrplanVorgabeWirdZwischenPunktenLinearInterpoliert(): void
|
||||
{
|
||||
$punkte = [
|
||||
['time' => '2026-09-30T11:35:00+00:00', 'gridTargetW' => 20486.254],
|
||||
['time' => '2026-09-30T11:30:00+00:00', 'gridTargetW' => 15664.283],
|
||||
];
|
||||
|
||||
$vorgabe = ManagerRegler::interpolierteFahrplanVorgabe(
|
||||
$punkte,
|
||||
(int) strtotime('2026-09-30T11:32:30+00:00')
|
||||
);
|
||||
|
||||
self::assertEqualsWithDelta(18075.2685, $vorgabe, 0.0001);
|
||||
}
|
||||
|
||||
public function testFahrplanVorgabeVerwendetRandpunktInnerhalbDerToleranz(): void
|
||||
{
|
||||
$punkte = [
|
||||
['time' => '2026-09-30T11:30:00+00:00', 'gridTargetW' => 15664.283],
|
||||
['time' => '2026-09-30T11:35:00+00:00', 'gridTargetW' => 20486.254],
|
||||
];
|
||||
|
||||
self::assertSame(
|
||||
15664.283,
|
||||
ManagerRegler::interpolierteFahrplanVorgabe(
|
||||
$punkte,
|
||||
(int) strtotime('2026-09-30T11:29:44+00:00')
|
||||
)
|
||||
);
|
||||
self::assertSame(
|
||||
20486.254,
|
||||
ManagerRegler::interpolierteFahrplanVorgabe(
|
||||
$punkte,
|
||||
(int) strtotime('2026-09-30T11:35:10+00:00')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testFahrplanVorgabeVerwirftZuGrosseZeitlicheLuecke(): void
|
||||
{
|
||||
$punkte = [
|
||||
['time' => '2026-09-30T11:00:00+00:00', 'gridTargetW' => 10000],
|
||||
['time' => '2026-09-30T12:00:00+00:00', 'gridTargetW' => 20000],
|
||||
];
|
||||
|
||||
self::assertNull(
|
||||
ManagerRegler::interpolierteFahrplanVorgabe(
|
||||
$punkte,
|
||||
(int) strtotime('2026-09-30T11:31:00+00:00')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function testOhnePeakBegrenzungBleibtSolarladenAktiv(): void
|
||||
{
|
||||
self::assertSame(
|
||||
@@ -113,21 +166,382 @@ final class ManagerReglerTest extends TestCase
|
||||
self::assertSame(500, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testNichtAenderbarerVerbraucherReserviertSeineIstleistung(): void
|
||||
public function testGleicheNaechsteLeistungBevorzugtWenigerEnergie(): void
|
||||
{
|
||||
$fest = $this->verbraucher(10, 0, [0, 3000], 3000, false);
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
0.0,
|
||||
3000.0,
|
||||
[
|
||||
$this->verbraucher(10, 0, [0, 3000], 0),
|
||||
$this->verbraucher(20, 0, [0, 3000], 0),
|
||||
],
|
||||
'PrioritaetPV',
|
||||
[10 => 3000.0, 20 => 0.0]
|
||||
);
|
||||
|
||||
self::assertSame([20 => 3000, 10 => 0], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testEnergiegleichstandVergleichtExakteWhStattZweiKilowattstundenGruppen(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
0.0,
|
||||
3000.0,
|
||||
[
|
||||
$this->verbraucher(10, 0, [0, 3000], 0),
|
||||
$this->verbraucher(20, 0, [0, 3000], 0),
|
||||
],
|
||||
'PrioritaetPV',
|
||||
[10 => 999.0, 20 => 0.0]
|
||||
);
|
||||
|
||||
self::assertSame([10 => 0, 20 => 3000], $ergebnis['Sollwerte']);
|
||||
}
|
||||
|
||||
/** @dataProvider beispielBudgets */
|
||||
public function testBestaetigteStufenfolge(int $budget, int $a, int $b): void
|
||||
{
|
||||
foreach (['PrioritaetPV', 'PrioritaetPeak'] as $feld) {
|
||||
$ergebnis = ManagerRegler::verteile(0.0, (float) $budget, [
|
||||
$this->verbraucher(20, 2, [0, 100, 110, 500, 780, 1500], 0),
|
||||
$this->verbraucher(10, 2, [0, 100, 200, 400, 800], 0),
|
||||
], $feld, [10 => 10000.0, 20 => 12000.0]);
|
||||
|
||||
self::assertSame([10 => $a, 20 => $b], $ergebnis['Sollwerte']);
|
||||
self::assertSame($budget - $a - $b, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
}
|
||||
|
||||
public function beispielBudgets(): array
|
||||
{
|
||||
return [
|
||||
[0, 0, 0], [99, 0, 0], [100, 100, 0], [199, 100, 0],
|
||||
[200, 100, 100], [209, 100, 100], [210, 100, 110],
|
||||
[309, 100, 110], [310, 200, 110], [509, 200, 110],
|
||||
[510, 400, 110], [899, 400, 110], [900, 400, 500],
|
||||
[1179, 400, 500], [1180, 400, 780], [1579, 400, 780],
|
||||
[1580, 800, 780], [2299, 800, 780], [2300, 800, 1500],
|
||||
[3000, 800, 1500],
|
||||
];
|
||||
}
|
||||
|
||||
public function testLeistungsniveauGehtVorEnergieUndSchrittdifferenz(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 220.0, [
|
||||
$this->verbraucher(10, 0, [0, 100, 200], 0),
|
||||
$this->verbraucher(20, 0, [0, 110, 120], 0),
|
||||
], 'PrioritaetPV', [10 => 0.0, 20 => 50000.0]);
|
||||
|
||||
self::assertSame([10 => 100, 20 => 120], $ergebnis['Sollwerte']);
|
||||
}
|
||||
|
||||
public function testPicoUndGoETeilenBudgetTrotzGrossemEnergieunterschied(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 12312.0, [
|
||||
$this->verbraucher(31986, 2, array_merge([0], range(4104, 12312, 684)), 0),
|
||||
$this->verbraucher(43878, 2, array_merge([0], range(4104, 10944, 684)), 0),
|
||||
], 'PrioritaetPV', [31986 => 41192.78, 43878 => 128082.87]);
|
||||
|
||||
self::assertSame([31986 => 6156, 43878 => 6156], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testNegativeDefizitverteilungBehaeltBisherigeEnergiegruppen(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(300.0, 0.0, [
|
||||
$this->verbraucher(10, 0, [['Von_W' => -300, 'Bis_W' => 300]], 0),
|
||||
$this->verbraucher(20, 0, [['Von_W' => -300, 'Bis_W' => 300]], 0),
|
||||
], 'PrioritaetPeak', [10 => 999.0, 20 => 0.0]);
|
||||
|
||||
self::assertSame([10 => -300, 20 => 0], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testGleicheLeistungUndEnergieEntscheidetStabilNachInstanzID(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 100.0, [
|
||||
$this->verbraucher(20, 0, [0, 100], 0),
|
||||
$this->verbraucher(10, 0, [0, 100], 0),
|
||||
], 'PrioritaetPV');
|
||||
|
||||
self::assertSame([10 => 100, 20 => 0], $ergebnis['Sollwerte']);
|
||||
}
|
||||
|
||||
public function testNichtPassenderSchrittBlockiertKeineAndereErreichbareStufe(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 210.0, [
|
||||
$this->verbraucher(10, 0, [0, 100, 200], 0),
|
||||
$this->verbraucher(20, 0, [0, 150], 0),
|
||||
], 'PrioritaetPV');
|
||||
|
||||
self::assertSame([10 => 200, 20 => 0], $ergebnis['Sollwerte']);
|
||||
self::assertSame(10, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testPeakVerwendetPeakPrioritaetVorLeistungUndEnergie(): void
|
||||
{
|
||||
$a = $this->verbraucher(10, 0, [0, 100, 200], 0);
|
||||
$b = $this->verbraucher(20, 9, [0, 300], 0);
|
||||
$a['Daten']['PrioritaetPeak'] = 9;
|
||||
$b['Daten']['PrioritaetPeak'] = 0;
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 300.0, [$a, $b], 'PrioritaetPeak', [20 => 50000.0]);
|
||||
|
||||
self::assertSame([20 => 300, 10 => 0], $ergebnis['Sollwerte']);
|
||||
}
|
||||
|
||||
public function testGrosseBereicheWerdenGemeinsamOhneWattweiseSchleifeVerteilt(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(0.0, 1000000001.0, [
|
||||
$this->verbraucher(10, 0, [['Von_W' => 0, 'Bis_W' => 1000000000]], 0),
|
||||
$this->verbraucher(20, 0, [['Von_W' => 0, 'Bis_W' => 1000000000]], 0),
|
||||
], 'PrioritaetPV', [10 => 999.0, 20 => 0.0]);
|
||||
|
||||
self::assertSame([10 => 500000000, 20 => 500000001], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testBereicheUndEinzelwerteGegenWattweiseReferenz(): void
|
||||
{
|
||||
$angebote = [
|
||||
[0, ['Von_W' => 3, 'Bis_W' => 9], 15],
|
||||
[0, 5, ['Von_W' => 7, 'Bis_W' => 12], 18],
|
||||
[0, 4, 8, ['Von_W' => 14, 'Bis_W' => 20]],
|
||||
];
|
||||
$energien = [10 => 10000.5, 20 => 10000.0, 30 => 10000.0];
|
||||
$verbraucher = [];
|
||||
$diskret = [];
|
||||
foreach ($angebote as $index => $angebot) {
|
||||
$id = ($index + 1) * 10;
|
||||
$verbraucher[] = $this->verbraucher($id, 2, $angebot, 0);
|
||||
$diskret[$id] = [];
|
||||
foreach ($angebot as $wert) {
|
||||
foreach (is_int($wert) ? [$wert] : range($wert['Von_W'], $wert['Bis_W']) as $watt) {
|
||||
$diskret[$id][] = $watt;
|
||||
}
|
||||
}
|
||||
}
|
||||
for ($budget = 0; $budget <= 60; $budget++) {
|
||||
$erwartet = [10 => 0, 20 => 0, 30 => 0];
|
||||
$rest = $budget;
|
||||
while (true) {
|
||||
$schritte = [];
|
||||
foreach ($diskret as $id => $werte) {
|
||||
foreach ($werte as $wert) {
|
||||
if ($wert > $erwartet[$id]) {
|
||||
if ($wert - $erwartet[$id] <= $rest) {
|
||||
$schritte[] = [$wert, $energien[$id], $id];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($schritte === []) {
|
||||
break;
|
||||
}
|
||||
sort($schritte);
|
||||
[$wert, , $id] = $schritte[0];
|
||||
$rest -= $wert - $erwartet[$id];
|
||||
$erwartet[$id] = $wert;
|
||||
}
|
||||
$ergebnis = ManagerRegler::verteile(0.0, (float) $budget, array_reverse($verbraucher),
|
||||
'PrioritaetPV', $energien);
|
||||
$ist = $ergebnis['Sollwerte'];
|
||||
ksort($ist);
|
||||
self::assertSame($erwartet, $ist, 'Budget: ' . $budget);
|
||||
self::assertSame($rest, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testNichtVerfuegbareLastWirdReserviertUndNichtNeuZugeteilt(): void
|
||||
{
|
||||
$a = $this->verbraucher(10, 0, [0, 100, 200], 100);
|
||||
$a['Daten']['Verfuegbar'] = false;
|
||||
$ergebnis = ManagerRegler::verteile(100.0, 200.0, [
|
||||
$a,
|
||||
$this->verbraucher(20, 0, [0, 100, 200], 0),
|
||||
], 'PrioritaetPV');
|
||||
|
||||
self::assertSame(100, $ergebnis['NichtSteuerbar_W']);
|
||||
self::assertSame([20 => 100], $ergebnis['Sollwerte']);
|
||||
}
|
||||
|
||||
public function testFestwertWirdUnabhaengigVonAenderungMoeglichZugewiesen(): void
|
||||
{
|
||||
$fest = $this->verbraucher(10, 0, [3000], 3000, false);
|
||||
$steuerbar = $this->verbraucher(20, 1, [0, ['Von_W' => 1, '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([10 => 3000, 20 => 2000], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testFestwerteWerdenNachIstleistungBilanziertUndAlsBefehlBeibehalten(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
-2411.0,
|
||||
0.0,
|
||||
[
|
||||
$this->verbraucher(
|
||||
44234,
|
||||
0,
|
||||
[['Von_W' => -39000, 'Bis_W' => 39000]],
|
||||
0
|
||||
),
|
||||
$this->verbraucher(38676, 1, [0, 4600], 0),
|
||||
$this->verbraucher(29743, 1, [0, 6500], 0),
|
||||
$this->verbraucher(43878, 2, [13850], 10151, false),
|
||||
$this->verbraucher(46052, 2, [11000], 0, false),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(12562, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame(10151, $ergebnis['NichtSteuerbar_W']);
|
||||
self::assertSame(
|
||||
[
|
||||
44234 => 2411,
|
||||
29743 => 0,
|
||||
38676 => 0,
|
||||
43878 => 13850,
|
||||
46052 => 11000,
|
||||
],
|
||||
$ergebnis['Sollwerte']
|
||||
);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testBatterieGleichtGemesseneFesteLastenAusOhneSolarladungZuSpeisen(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
-4526.0,
|
||||
-50.0,
|
||||
[
|
||||
$this->verbraucher(29743, 1, [0, 6500], 6500),
|
||||
$this->verbraucher(38676, 1, [0, 4600], 4600),
|
||||
$this->verbraucher(22078, 2, [7690], 0, false),
|
||||
$this->verbraucher(31986, 2, [11000], 10942, false),
|
||||
$this->verbraucher(43878, 2, [0, 4150, 10300], 9908),
|
||||
$this->verbraucher(
|
||||
44234,
|
||||
6,
|
||||
[['Von_W' => -39000, 'Bis_W' => 39000]],
|
||||
-22769
|
||||
),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(13657, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame(10942, $ergebnis['NichtSteuerbar_W']);
|
||||
self::assertSame(
|
||||
[
|
||||
29743 => 0,
|
||||
38676 => 0,
|
||||
22078 => 7690,
|
||||
31986 => 11000,
|
||||
43878 => 0,
|
||||
44234 => 2715,
|
||||
],
|
||||
$ergebnis['Sollwerte']
|
||||
);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testBegrenztesFahrzeugLaedtVorBatterieOhneBatterieentladung(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
-3587.0,
|
||||
0.0,
|
||||
[
|
||||
$this->verbraucher(
|
||||
10,
|
||||
0,
|
||||
[0, 4150, 4850, 5550, 6250, 6950, 7600, 8300, 9000, 9700, 10300, 11000],
|
||||
10263
|
||||
),
|
||||
$this->verbraucher(
|
||||
20,
|
||||
1,
|
||||
[['Von_W' => -39000, 'Bis_W' => 39000]],
|
||||
0
|
||||
),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(13850, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame([10 => 11000, 20 => 2850], $ergebnis['Sollwerte']);
|
||||
self::assertGreaterThanOrEqual(0, $ergebnis['Sollwerte'][20]);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testPvBetriebNutztBeiPositivemBudgetKeineNegativeLeistungsstufe(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
0.0,
|
||||
500.0,
|
||||
[
|
||||
$this->verbraucher(10, 0, [-1000, 5000], 0),
|
||||
$this->verbraucher(20, 1, [0, 3000], 0),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(500, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame([10 => 5000, 20 => 0], $ergebnis['Sollwerte']);
|
||||
self::assertGreaterThanOrEqual(0, $ergebnis['Sollwerte'][10]);
|
||||
self::assertSame(-4500, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testPvBetriebNutztNegativeLeistungErstBeiTatsaechlichemDefizit(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
1000.0,
|
||||
0.0,
|
||||
[
|
||||
$this->verbraucher(
|
||||
10,
|
||||
0,
|
||||
[['Von_W' => -5000, 'Bis_W' => 5000]],
|
||||
0
|
||||
),
|
||||
$this->verbraucher(20, 1, [0, 3000], 0),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(-1000, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame([10 => -1000, 20 => 0], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testFestgehaltenerVerbraucherBilanziertIstUndBehaeltSollwert(): void
|
||||
{
|
||||
$festgehalten = $this->verbraucher(10, 0, [0, 3000, 5000], 2000, false);
|
||||
$festgehalten['Daten']['Zustand'][0]['Wert'] = 3000;
|
||||
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
0.0,
|
||||
3000.0,
|
||||
[
|
||||
$festgehalten,
|
||||
$this->verbraucher(20, 1, [['Von_W' => 0, 'Bis_W' => 5000]], 0),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame(5000, $ergebnis['Verteilbudget_W']);
|
||||
self::assertSame(2000, $ergebnis['NichtSteuerbar_W']);
|
||||
self::assertSame([10 => 3000, 20 => 3000], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void
|
||||
{
|
||||
$verbraucher = $this->verbraucher(10, 0, [0, 3000], null, false);
|
||||
$verbraucher = $this->verbraucher(10, 0, [3000], null, false);
|
||||
$verbraucher['Daten']['Leistungsquelle'] = Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN;
|
||||
$verbraucher['Daten']['Istleistung_W'] = null;
|
||||
$verbraucher['Daten']['Zustand'][0]['Wert'] = 3000;
|
||||
@@ -135,9 +549,31 @@ final class ManagerReglerTest extends TestCase
|
||||
$ergebnis = ManagerRegler::verteile(3000.0, 0.0, [$verbraucher], 'PrioritaetPV');
|
||||
|
||||
self::assertSame(3000, $ergebnis['AktuelleVerbraucherleistung_W']);
|
||||
self::assertSame([10 => 3000], $ergebnis['Sollwerte']);
|
||||
self::assertSame(-3000, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testBatterieMitTieferPrioritaetUebernimmtNichtNutzbarenRest(): void
|
||||
{
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
-5000.0,
|
||||
0.0,
|
||||
[
|
||||
$this->verbraucher(20, 1, [0, 3000, 6000], 0),
|
||||
$this->verbraucher(
|
||||
10,
|
||||
6,
|
||||
[['Von_W' => 0, 'Bis_W' => 10000]],
|
||||
0
|
||||
),
|
||||
],
|
||||
'PrioritaetPV'
|
||||
);
|
||||
|
||||
self::assertSame([20 => 3000, 10 => 2000], $ergebnis['Sollwerte']);
|
||||
self::assertSame(0, $ergebnis['Restabweichung_W']);
|
||||
}
|
||||
|
||||
public function testMonatlicheGrenzeMussVollstaendigSein(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Manager;
|
||||
use ManagerSDLRuntime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @runTestsInSeparateProcesses
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
final class ManagerSDLTest extends TestCase
|
||||
{
|
||||
private Manager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once __DIR__ . '/Support/ManagerSDLRuntime.php';
|
||||
require_once __DIR__ . '/../Manager/module.php';
|
||||
|
||||
ManagerSDLRuntime::reset();
|
||||
$this->manager = new Manager(42);
|
||||
$this->manager->Create();
|
||||
foreach ([101 => 1000.0, 102 => 5000.0, 103 => 200.0, 104 => 1500.0, 105 => 70.0] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
$this->properties([
|
||||
'NetzleistungVariableID' => 101,
|
||||
'AnlagenWechselrichter' => $this->json([
|
||||
['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102],
|
||||
]),
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'battery', 'LeistungVariableID' => 103],
|
||||
]),
|
||||
'SDLLeistungVariableID' => 104,
|
||||
'SDLSOCVariableID' => 105,
|
||||
]);
|
||||
foreach ([
|
||||
'SDLLeistungArchiv', 'SDLSOCArchiv', 'SDLLadenEnergie', 'SDLEntladenEnergie',
|
||||
] as $ident) {
|
||||
ManagerSDLRuntime::child(42, $ident);
|
||||
}
|
||||
ManagerSDLRuntime::child(42, 'SDLStatus', 3);
|
||||
}
|
||||
|
||||
public function testSDLByDefaultInactiveDoesNotRequireSources(): void
|
||||
{
|
||||
self::assertFalse($this->manager->testProperties['SDLAktiv']);
|
||||
self::assertFalse($this->manager->testProperties['SDLEnergieflussAnzeigen']);
|
||||
self::assertFalse($this->manager->testProperties['SDLDiagrammeAnzeigen']);
|
||||
unset(ManagerSDLRuntime::$variables[104], ManagerSDLRuntime::$variables[105]);
|
||||
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
$powers = $this->invoke('leseMessleistungen');
|
||||
|
||||
self::assertSame(0.0, $powers['SDL']);
|
||||
self::assertSame(5800.0, $powers['Haus']);
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
}
|
||||
|
||||
/** @dataProvider signedPowerProvider */
|
||||
public function testSDLPowerIsExcludedFromHouseConsumption(float $raw, float $factor, float $sdl, float $house): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]);
|
||||
ManagerSDLRuntime::variable(104, $raw);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
|
||||
self::assertSame([
|
||||
'PV' => 5000.0, 'Haus' => $house, 'Netz' => 1000.0, 'Batterie' => 200.0, 'SDL' => $sdl,
|
||||
], $this->invoke('leseMessleistungen'));
|
||||
}
|
||||
|
||||
public static function signedPowerProvider(): array
|
||||
{
|
||||
return [
|
||||
'charging' => [1500.0, 1.0, 1500.0, 4300.0],
|
||||
'discharging' => [-1500.0, 1.0, -1500.0, 7300.0],
|
||||
'kilowatts' => [2.5, 1000.0, 2500.0, 3300.0],
|
||||
'inverted source' => [1500.0, -1.0, -1500.0, 7300.0],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider duplicatePowerProvider */
|
||||
public function testRejectsSDLPowerAlsoUsedByAnotherEnergySource(int $duplicateID): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungVariableID' => $duplicateID]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('nicht zugleich');
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
public static function duplicatePowerProvider(): array
|
||||
{
|
||||
return ['grid' => [101], 'PV' => [102], 'battery' => [103]];
|
||||
}
|
||||
|
||||
public function testRejectsSOCUsingThePowerVariable(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 104]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('separate numerische Variable');
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
/** @dataProvider invalidFactorProvider */
|
||||
public function testRejectsInvalidSDLFactors(float $factor): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
public static function invalidFactorProvider(): array
|
||||
{
|
||||
return ['zero' => [0.0], 'infinite' => [INF], 'not a number' => [NAN]];
|
||||
}
|
||||
|
||||
public function testMissingSDLPowerFailsWithoutReturningHouseConsumption(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
unset(ManagerSDLRuntime::$variables[104]);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('nicht verfuegbar');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
/** @dataProvider invalidPowerProvider */
|
||||
public function testNonfiniteOrNonnumericPowerDoesNotBecomeZero($value): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(104, $value);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('keine endliche Zahl');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
public static function invalidPowerProvider(): array
|
||||
{
|
||||
return ['infinite' => [INF], 'negative infinite' => [-INF], 'not a number' => [NAN], 'string' => ['1500']];
|
||||
}
|
||||
|
||||
public function testScaledSDLPowerOverflowIsRejected(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => 2.0]);
|
||||
ManagerSDLRuntime::variable(104, PHP_FLOAT_MAX);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('skalierte SDL-Istleistung');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
public function testSDLPowerMustBeANumericVariable(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(104, '1500', 3);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
/** @dataProvider socProvider */
|
||||
public function testSDLSOCBoundaries(float $value, ?float $expected): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(105, $value);
|
||||
self::assertSame($expected, $this->invoke('aktuelleSDLSOC'));
|
||||
if ($expected === null) {
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function socProvider(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [0.0, 0.0], 'full' => [100.0, 100.0], 'normal' => [37.5, 37.5],
|
||||
'negative' => [-0.1, null], 'overfull' => [100.1, null], 'infinite' => [INF, null], 'NaN' => [NAN, null],
|
||||
];
|
||||
}
|
||||
|
||||
public function testSDLSOCMayBeOmittedAndDisappearingSourceIsIgnored(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 0]);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
|
||||
$this->properties(['SDLSOCVariableID' => 105]);
|
||||
unset(ManagerSDLRuntime::$variables[105]);
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
|
||||
public function testPowerReadingPreservesSOCErrorUntilRecovery(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(105, 101.0);
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
ManagerSDLRuntime::variable(105, 47.5);
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertSame('Aktiv', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
|
||||
public function testSDLSOCDoesNotChangeWeightedNormalBatterySOC(): void
|
||||
{
|
||||
ManagerSDLRuntime::variable(106, 20.0);
|
||||
ManagerSDLRuntime::variable(107, 80.0);
|
||||
$this->properties([
|
||||
'SDLAktiv' => true,
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'small', 'SOCVariableID' => 106, 'Nutzkapazitaet_kWh' => 10.0],
|
||||
['ID' => 'large', 'SOCVariableID' => 107, 'Nutzkapazitaet_kWh' => 30.0],
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc'));
|
||||
self::assertSame(70.0, $this->invoke('aktuelleSDLSOC'));
|
||||
ManagerSDLRuntime::variable(105, 0.0);
|
||||
self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc'));
|
||||
}
|
||||
|
||||
public function testCounterDeltasExcludeSDLChargingAndIncludeDischarging(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$baseline = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
self::assertCount(8, $baseline);
|
||||
self::assertSame(array_fill_keys(array_keys($baseline), 0.0), $baseline);
|
||||
$this->advanceCounters();
|
||||
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
|
||||
self::assertEqualsWithDelta(2.1, $deltas['Hausenergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001);
|
||||
self::assertSame(0.6, $deltas['SDLLadenEnergie']);
|
||||
self::assertSame(0.2, $deltas['SDLEntladenEnergie']);
|
||||
self::assertEqualsWithDelta(0.4, $deltas['BatterieLadenEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.1, $deltas['BatterieEntladenEnergie'], 0.000001);
|
||||
}
|
||||
|
||||
public function testMissingSDLIntervalDoesNotPolluteHouseEnergyOrTheNextInterval(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$this->invoke('leseEnergieZaehlerDeltas');
|
||||
$this->advanceCounters();
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.0, 0.0, false]);
|
||||
|
||||
self::assertSame(0.0, $deltas['Hausenergie']);
|
||||
self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(1.0, $deltas['NetzbezugEnergie'], 0.000001);
|
||||
ManagerSDLRuntime::variable(201, 102.5);
|
||||
$next = $this->invoke('leseEnergieZaehlerDeltas', [0.1, 0.0, true]);
|
||||
self::assertEqualsWithDelta(0.4, $next['Hausenergie'], 0.000001);
|
||||
}
|
||||
|
||||
public function testCounterResetSuppressesHouseEnergyForActiveSDL(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$this->invoke('leseEnergieZaehlerDeltas');
|
||||
$this->advanceCounters();
|
||||
ManagerSDLRuntime::variable(202, 0.0);
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
self::assertSame(0.0, $deltas['Hausenergie']);
|
||||
self::assertSame(0.0, $deltas['NetzbezugEnergie']);
|
||||
self::assertSame(0.6, $deltas['SDLLadenEnergie']);
|
||||
}
|
||||
|
||||
public function testWithoutConfiguredCountersReturnsNull(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
self::assertNull($this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]));
|
||||
}
|
||||
|
||||
public function testChangingSDLSourceResetsBaselinesButKeepsCollectedEnergy(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
$this->manager->testAttributes['MessungLetzterZeitstempel'] = 123;
|
||||
$this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung'] = 100;
|
||||
$this->manager->testAttributes['MessungZaehlerstaende'] = '{"pv:pv":5.0}';
|
||||
$this->manager->testAttributes['MessungEnergiePuffer'] = '{"Hausenergie":1.5}';
|
||||
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
self::assertSame(123, $this->manager->testAttributes['MessungLetzterZeitstempel']);
|
||||
$this->properties(['SDLLeistungsfaktor' => -1.0]);
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
|
||||
self::assertSame(0, $this->manager->testAttributes['MessungLetzterZeitstempel']);
|
||||
self::assertSame(0, $this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung']);
|
||||
self::assertSame('{}', $this->manager->testAttributes['MessungZaehlerstaende']);
|
||||
self::assertSame('{"Hausenergie":1.5}', $this->manager->testAttributes['MessungEnergiePuffer']);
|
||||
}
|
||||
|
||||
/** @dataProvider chartProvider */
|
||||
public function testChartMergePreservesCustomizationsAndOnlyRemovesSDL(bool $energy): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLDiagrammeAnzeigen' => true]);
|
||||
$powerIdent = $energy ? 'SDLLadenEnergie' : 'SDLLeistungArchiv';
|
||||
$custom = ['variableID' => 999, 'title' => 'Custom series', 'strokeColor' => '#ABCDEF'];
|
||||
$sdl = ['variableID' => $this->manager->GetIDForIdent($powerIdent), 'title' => 'Own SDL name', 'visible' => false];
|
||||
ManagerSDLRuntime::$media[301] = base64_encode($this->json([
|
||||
'datasets' => [$custom, $sdl], 'customOption' => ['keep' => true],
|
||||
]));
|
||||
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
$chart = $this->chart();
|
||||
self::assertSame($custom, $chart['datasets'][0]);
|
||||
self::assertSame($sdl, $chart['datasets'][1]);
|
||||
self::assertSame(['keep' => true], $chart['customOption']);
|
||||
self::assertCount($energy ? 4 : 3, $chart['datasets']);
|
||||
$soc = $chart['datasets'][count($chart['datasets']) - 1];
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $soc['variableID']);
|
||||
self::assertSame('right', $soc['side']);
|
||||
if ($energy) {
|
||||
self::assertSame('bar', $chart['datasets'][2]['type']);
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLEntladenEnergie'), $chart['datasets'][2]['variableID']);
|
||||
}
|
||||
|
||||
$writes = ManagerSDLRuntime::$writes;
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
self::assertSame($writes, ManagerSDLRuntime::$writes);
|
||||
|
||||
$this->properties(['SDLDiagrammeAnzeigen' => false]);
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
self::assertSame([$custom], $this->chart()['datasets']);
|
||||
self::assertSame(['keep' => true], $this->chart()['customOption']);
|
||||
}
|
||||
|
||||
public static function chartProvider(): array
|
||||
{
|
||||
return ['power chart' => [false], 'energy chart' => [true]];
|
||||
}
|
||||
|
||||
public function testEnergyFlowMergePreservesCustomNodesAndIsIdempotent(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLEnergieflussAnzeigen' => true]);
|
||||
$custom = ['VariableID' => 999, 'Name' => 'Custom consumer', 'Type' => 'consumer', 'Invert' => true];
|
||||
ManagerSDLRuntime::$properties[302]['Config'] = $this->json([$custom]);
|
||||
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
$nodes = $this->flow();
|
||||
self::assertSame($custom, $nodes[0]);
|
||||
self::assertCount(2, $nodes);
|
||||
self::assertSame('storage', $nodes[1]['Type']);
|
||||
self::assertSame($this->manager->GetIDForIdent('EnergieflussSDL'), $nodes[1]['VariableID']);
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $nodes[1]['InfoVariableID']);
|
||||
self::assertTrue(ManagerSDLRuntime::$hidden[$nodes[1]['VariableID']]);
|
||||
|
||||
$nodes[1]['Name'] = 'My SDL';
|
||||
$nodes[1]['Invert'] = true;
|
||||
ManagerSDLRuntime::$properties[302]['Config'] = $this->json($nodes);
|
||||
$writes = ManagerSDLRuntime::$writes;
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame($nodes, $this->flow());
|
||||
self::assertSame($writes, ManagerSDLRuntime::$writes);
|
||||
|
||||
$this->properties(['SDLSOCVariableID' => 0]);
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame(1, $this->flow()[1]['InfoVariableID']);
|
||||
self::assertSame('My SDL', $this->flow()[1]['Name']);
|
||||
|
||||
$this->properties(['SDLAktiv' => false]);
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame([$custom], $this->flow());
|
||||
}
|
||||
|
||||
private function configureCounters(): void
|
||||
{
|
||||
$this->properties([
|
||||
'SDLAktiv' => true,
|
||||
'NetzbezugEnergieVariableID' => 202,
|
||||
'NetzeinspeisungEnergieVariableID' => 203,
|
||||
'AnlagenWechselrichter' => $this->json([
|
||||
['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'ErzeugungsenergieVariableID' => 201],
|
||||
]),
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'battery', 'LeistungVariableID' => 103, 'LadeenergieVariableID' => 204, 'EntladeenergieVariableID' => 205],
|
||||
]),
|
||||
]);
|
||||
foreach ([201 => 100.0, 202 => 10.0, 203 => 5.0, 204 => 10.0, 205 => 8.0] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private function advanceCounters(): void
|
||||
{
|
||||
foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private function properties(array $properties): void
|
||||
{
|
||||
$this->manager->testProperties = array_replace($this->manager->testProperties, $properties);
|
||||
}
|
||||
|
||||
private function invoke(string $name, array $arguments = [])
|
||||
{
|
||||
$method = new ReflectionMethod(Manager::class, $name);
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
$method->setAccessible(true);
|
||||
}
|
||||
|
||||
return $method->invokeArgs($this->manager, $arguments);
|
||||
}
|
||||
|
||||
private function json(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
|
||||
}
|
||||
|
||||
private function chart(): array
|
||||
{
|
||||
return json_decode(base64_decode(ManagerSDLRuntime::$media[301]), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function flow(): array
|
||||
{
|
||||
return json_decode(ManagerSDLRuntime::$properties[302]['Config'], true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,17 @@ final class NachrichtenvertragTest extends TestCase
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testLeistungswertWirdInEinzelwertenUndBereichenGefunden(): void
|
||||
{
|
||||
$angebot = [0, ['Von_W' => 1000, 'Bis_W' => 2000], 3000];
|
||||
|
||||
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 0));
|
||||
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 1537));
|
||||
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2000));
|
||||
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 999));
|
||||
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2500));
|
||||
}
|
||||
|
||||
public function testNegativePrioritaetWirdAbgelehnt(): void
|
||||
{
|
||||
$daten = $this->verbraucherdaten();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\StoerungsSnapshot;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class StoerungsSnapshotTest extends TestCase
|
||||
{
|
||||
private const INSTALLATION_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
|
||||
public function testSnapshotNormalisiertDedupliziertUndSortiertMeldungen(): void
|
||||
{
|
||||
$snapshot = StoerungsSnapshot::erstellen(
|
||||
self::INSTALLATION_ID,
|
||||
42,
|
||||
'main',
|
||||
true,
|
||||
[
|
||||
[
|
||||
'sourceType' => 'consumer',
|
||||
'sourceId' => 'ips:8',
|
||||
'sourceName' => ' Boiler ',
|
||||
'code' => 'Temp.High',
|
||||
'severity' => 'warning',
|
||||
'message' => " Temperatur hoch ",
|
||||
],
|
||||
[
|
||||
'sourceType' => 'manager',
|
||||
'sourceId' => 'ips:42',
|
||||
'sourceName' => 'Manager',
|
||||
'code' => 'Grid.Missing',
|
||||
'severity' => 'critical',
|
||||
'message' => 'Netzmessung fehlt',
|
||||
],
|
||||
[
|
||||
'sourceType' => 'consumer',
|
||||
'sourceId' => 'ips:8',
|
||||
'sourceName' => 'Boiler',
|
||||
'code' => 'Temp.High',
|
||||
'severity' => 'warning',
|
||||
'message' => 'Temperatur sehr hoch',
|
||||
],
|
||||
],
|
||||
1_700_000_000
|
||||
);
|
||||
|
||||
self::assertSame('1.0', $snapshot['version']);
|
||||
self::assertSame('2023-11-14T22:13:20Z', $snapshot['capturedAt']);
|
||||
self::assertSame(
|
||||
['Grid.Missing', 'Temp.High'],
|
||||
array_column($snapshot['faults'], 'code')
|
||||
);
|
||||
self::assertSame('Temperatur sehr hoch', $snapshot['faults'][1]['message']);
|
||||
self::assertSame(
|
||||
['instanceId' => 42, 'role' => 'main', 'active' => true],
|
||||
$snapshot['manager']
|
||||
);
|
||||
}
|
||||
|
||||
public function testZustandHashIgnoriertDenErfassungszeitpunkt(): void
|
||||
{
|
||||
$a = StoerungsSnapshot::erstellen(
|
||||
self::INSTALLATION_ID,
|
||||
42,
|
||||
'standalone',
|
||||
false,
|
||||
[],
|
||||
1_700_000_000
|
||||
);
|
||||
$b = StoerungsSnapshot::erstellen(
|
||||
self::INSTALLATION_ID,
|
||||
42,
|
||||
'standalone',
|
||||
false,
|
||||
[],
|
||||
1_700_000_300
|
||||
);
|
||||
|
||||
self::assertSame(StoerungsSnapshot::zustandHash($a), StoerungsSnapshot::zustandHash($b));
|
||||
}
|
||||
|
||||
public function testSnapshotBegrenztSichAufHundertPriorisierteMeldungen(): void
|
||||
{
|
||||
$meldungen = [];
|
||||
for ($index = 1; $index <= 101; ++$index) {
|
||||
$meldungen[] = [
|
||||
'sourceType' => 'consumer',
|
||||
'sourceId' => 'ips:' . $index,
|
||||
'sourceName' => sprintf('Quelle %03d', $index),
|
||||
'code' => 'Fault.' . $index,
|
||||
'severity' => $index === 101 ? 'critical' : 'warning',
|
||||
'message' => 'Testmeldung ' . $index,
|
||||
];
|
||||
}
|
||||
|
||||
$snapshot = StoerungsSnapshot::erstellen(
|
||||
self::INSTALLATION_ID,
|
||||
42,
|
||||
'standalone',
|
||||
true,
|
||||
$meldungen
|
||||
);
|
||||
|
||||
self::assertCount(100, $snapshot['faults']);
|
||||
self::assertSame('Fault.101', $snapshot['faults'][0]['code']);
|
||||
}
|
||||
|
||||
public function testUngueltigerSchweregradWirdAbgelehnt(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
StoerungsSnapshot::erstellen(
|
||||
self::INSTALLATION_ID,
|
||||
42,
|
||||
'standalone',
|
||||
true,
|
||||
[[
|
||||
'sourceType' => 'consumer',
|
||||
'sourceId' => 'ips:8',
|
||||
'sourceName' => 'Boiler',
|
||||
'code' => 'Temp.High',
|
||||
'severity' => 'alarm',
|
||||
'message' => 'Temperatur hoch',
|
||||
]]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Minimal Symcon runtime, loaded only inside isolated ManagerSDLTest processes. */
|
||||
final class ManagerSDLRuntime
|
||||
{
|
||||
public static array $variables = [];
|
||||
public static array $children = [];
|
||||
public static array $properties = [];
|
||||
public static array $media = [];
|
||||
public static array $writes = [];
|
||||
public static array $hidden = [];
|
||||
private static int $nextID = 10000;
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$variables = [];
|
||||
self::$children = [];
|
||||
self::$properties = [];
|
||||
self::$media = [];
|
||||
self::$writes = [];
|
||||
self::$hidden = [];
|
||||
self::$nextID = 10000;
|
||||
}
|
||||
|
||||
public static function variable(int $id, $value, int $type = 2): int
|
||||
{
|
||||
self::$variables[$id] = ['VariableType' => $type, 'Value' => $value];
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function child(int $parent, string $ident, int $type = 2): int
|
||||
{
|
||||
if (!isset(self::$children[$parent][$ident])) {
|
||||
$id = self::$nextID++;
|
||||
self::$children[$parent][$ident] = $id;
|
||||
self::variable($id, $type === 3 ? '' : 0.0, $type);
|
||||
}
|
||||
|
||||
return self::$children[$parent][$ident];
|
||||
}
|
||||
}
|
||||
|
||||
class IPSModule
|
||||
{
|
||||
public int $InstanceID;
|
||||
public array $testProperties = [];
|
||||
public array $testAttributes = [];
|
||||
|
||||
public function __construct(int $instanceID)
|
||||
{
|
||||
$this->InstanceID = $instanceID;
|
||||
}
|
||||
|
||||
public function Create(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function __call(string $method, array $arguments)
|
||||
{
|
||||
if (preg_match('/^(Register|Read|Write)(Property|Attribute)(Boolean|Integer|Float|String)$/', $method, $parts)) {
|
||||
$storage = $parts[2] === 'Property' ? 'testProperties' : 'testAttributes';
|
||||
$ident = $arguments[0];
|
||||
if ($parts[1] === 'Register') {
|
||||
$this->{$storage}[$ident] = $this->{$storage}[$ident] ?? $arguments[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
if ($parts[1] === 'Write') {
|
||||
$this->{$storage}[$ident] = $arguments[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
if (!array_key_exists($ident, $this->{$storage})) {
|
||||
throw new RuntimeException('Unregistered test property or attribute: ' . $ident);
|
||||
}
|
||||
|
||||
return $this->{$storage}[$ident];
|
||||
}
|
||||
if (preg_match('/^RegisterVariable(Boolean|Integer|Float|String)$/', $method, $parts)) {
|
||||
$types = ['Boolean' => 0, 'Integer' => 1, 'Float' => 2, 'String' => 3];
|
||||
|
||||
return ManagerSDLRuntime::child($this->InstanceID, $arguments[0], $types[$parts[1]]);
|
||||
}
|
||||
|
||||
throw new BadMethodCallException('Unsupported Symcon test call: ' . $method);
|
||||
}
|
||||
|
||||
public function EnableAction(string $ident): void
|
||||
{
|
||||
}
|
||||
|
||||
public function RegisterTimer(string $ident, int $interval, string $script): void
|
||||
{
|
||||
}
|
||||
|
||||
public function GetIDForIdent(string $ident): int
|
||||
{
|
||||
if (!isset(ManagerSDLRuntime::$children[$this->InstanceID][$ident])) {
|
||||
throw new RuntimeException('Unknown test child: ' . $ident);
|
||||
}
|
||||
|
||||
return ManagerSDLRuntime::$children[$this->InstanceID][$ident];
|
||||
}
|
||||
|
||||
public function SetValue(string $ident, $value): void
|
||||
{
|
||||
ManagerSDLRuntime::$variables[$this->GetIDForIdent($ident)]['Value'] = $value;
|
||||
}
|
||||
|
||||
public function GetValue(string $ident)
|
||||
{
|
||||
return GetValue($this->GetIDForIdent($ident));
|
||||
}
|
||||
}
|
||||
|
||||
function IPS_VariableExists(int $variableID): bool
|
||||
{
|
||||
return isset(ManagerSDLRuntime::$variables[$variableID]);
|
||||
}
|
||||
|
||||
function IPS_GetVariable(int $variableID): array
|
||||
{
|
||||
if (!IPS_VariableExists($variableID)) {
|
||||
throw new RuntimeException('Unknown test variable: ' . $variableID);
|
||||
}
|
||||
|
||||
return ManagerSDLRuntime::$variables[$variableID];
|
||||
}
|
||||
|
||||
function GetValue(int $variableID)
|
||||
{
|
||||
return IPS_GetVariable($variableID)['Value'];
|
||||
}
|
||||
|
||||
function IPS_GetObjectIDByIdent(string $ident, int $parentID)
|
||||
{
|
||||
return ManagerSDLRuntime::$children[$parentID][$ident] ?? false;
|
||||
}
|
||||
|
||||
function IPS_GetProperty(int $instanceID, string $property)
|
||||
{
|
||||
return ManagerSDLRuntime::$properties[$instanceID][$property];
|
||||
}
|
||||
|
||||
function IPS_SetProperty(int $instanceID, string $property, $value): void
|
||||
{
|
||||
ManagerSDLRuntime::$properties[$instanceID][$property] = $value;
|
||||
ManagerSDLRuntime::$writes[] = ['property', $instanceID, $property];
|
||||
}
|
||||
|
||||
function IPS_ApplyChanges(int $instanceID): void
|
||||
{
|
||||
ManagerSDLRuntime::$writes[] = ['apply', $instanceID];
|
||||
}
|
||||
|
||||
function IPS_GetMediaContent(int $mediaID): string
|
||||
{
|
||||
return ManagerSDLRuntime::$media[$mediaID];
|
||||
}
|
||||
|
||||
function IPS_SetMediaContent(int $mediaID, string $content): void
|
||||
{
|
||||
ManagerSDLRuntime::$media[$mediaID] = $content;
|
||||
ManagerSDLRuntime::$writes[] = ['media', $mediaID];
|
||||
}
|
||||
|
||||
function IPS_SetHidden(int $objectID, bool $hidden): void
|
||||
{
|
||||
ManagerSDLRuntime::$hidden[$objectID] = $hidden;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ return [
|
||||
'file' => __DIR__ . '/modules/Manager.php',
|
||||
'paths' => [
|
||||
'Manager/*',
|
||||
'libs/EinspeiseRegler.php',
|
||||
'libs/Lizenzpruefung.php',
|
||||
'libs/ManagerRegler.php',
|
||||
'libs/ManagerSchnittstelle.php',
|
||||
|
||||
@@ -119,10 +119,10 @@ PHP
|
||||
): void {
|
||||
IPS_RequestAction($batterieID, 'Aktiv', true);
|
||||
$managerdatenSenden('PV', null);
|
||||
$managerdatenSenden('PV', 500);
|
||||
$managerdatenSenden('PV', 537);
|
||||
|
||||
$test->waitUntil(
|
||||
static fn (): bool => (float) GetValue($leistungID) === 500.0
|
||||
static fn (): bool => (float) GetValue($leistungID) === 537.0
|
||||
&& (int) GetValue($modusID) === 11
|
||||
&& (int) GetValue($managementID) === 2
|
||||
);
|
||||
@@ -133,14 +133,19 @@ PHP
|
||||
'GoodWe-Entladen verwendet negativen Managerwert und Modus 12',
|
||||
static function (TestContext $test) use (
|
||||
$managerdatenSenden,
|
||||
$batterieID,
|
||||
$modusID,
|
||||
$leistungID
|
||||
): void {
|
||||
$managerdatenSenden('PV', -500);
|
||||
$managerdatenSenden('PV', -843);
|
||||
$test->waitUntil(
|
||||
static fn (): bool => (float) GetValue($leistungID) === 500.0
|
||||
static fn (): bool => (float) GetValue($leistungID) === 843.0
|
||||
&& (int) GetValue($modusID) === 12
|
||||
);
|
||||
$test->assertSame(-843, GetValue($test->objectByIdent('Sollleistung', $batterieID)));
|
||||
IPS_RequestAction($batterieID, 'VorgabeTimeout', 0);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $batterieID)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $batterieID)));
|
||||
}
|
||||
);
|
||||
|
||||
@@ -163,6 +168,32 @@ PHP
|
||||
}
|
||||
);
|
||||
|
||||
$test->runCase(
|
||||
'Benutzerdefinierte Nachladeleistung begrenzt das Schutzangebot',
|
||||
static function (TestContext $test) use (
|
||||
$batterieID,
|
||||
$ladezustandID,
|
||||
$managerdatenSenden
|
||||
): void {
|
||||
$test->configureInstance($batterieID, [
|
||||
'Batteriemanagement' => 2,
|
||||
'NachladenMitMaximalleistung' => false,
|
||||
'MaximaleNachladeleistung' => 400,
|
||||
]);
|
||||
IPS_RequestAction($batterieID, 'Aktiv', true);
|
||||
SetValue($ladezustandID, 19.0);
|
||||
$managerdatenSenden('PV', null);
|
||||
|
||||
$angebotID = $test->objectByIdent('LeistungsangebotDiagnose', $batterieID);
|
||||
$test->waitUntil(
|
||||
static fn (): bool => json_decode(
|
||||
(string) GetValue($angebotID),
|
||||
true
|
||||
) === [400]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
$test->runCase(
|
||||
'Wechselrichtersteuerung setzt Leistung auf null und Automatikmodus',
|
||||
static function (TestContext $test) use (
|
||||
@@ -171,6 +202,7 @@ PHP
|
||||
$leistungID
|
||||
): void {
|
||||
$test->configureInstance($batterieID, ['Batteriemanagement' => 1]);
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $batterieID)));
|
||||
$test->waitUntil(
|
||||
static fn (): bool => (float) GetValue($leistungID) === 0.0
|
||||
&& (int) GetValue($managementID) === 1
|
||||
|
||||
@@ -24,6 +24,38 @@ return static function (TestContext $test): void {
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('SignalR-Abbruch synchronisiert den Gatewaystatus', static function (
|
||||
TestContext $test
|
||||
) use ($gateway): void {
|
||||
IPS_RequestAction(
|
||||
$gateway,
|
||||
'TestSignalRFrame',
|
||||
json_encode(['type' => 7], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$test->assertInstanceStatus($gateway, 203);
|
||||
$test->assertSame(
|
||||
false,
|
||||
GetValue($test->objectByIdent('Connected', $gateway))
|
||||
);
|
||||
$test->assertSame(
|
||||
'Easee-Ereignisverbindung wurde beendet.',
|
||||
GetValue($test->objectByIdent('LastError', $gateway))
|
||||
);
|
||||
|
||||
IPS_RequestAction($gateway, 'TestSignalRFrame', '{}');
|
||||
|
||||
$test->assertInstanceStatus($gateway);
|
||||
$test->assertSame(
|
||||
true,
|
||||
GetValue($test->objectByIdent('Connected', $gateway))
|
||||
);
|
||||
$test->assertSame(
|
||||
'',
|
||||
GetValue($test->objectByIdent('LastError', $gateway))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Abonnement liefert injizierten API-Zustand', static function (
|
||||
TestContext $test
|
||||
) use ($gateway): void {
|
||||
@@ -72,6 +104,21 @@ return static function (TestContext $test): void {
|
||||
$test->assertSame(200, $antwort['httpCode']);
|
||||
});
|
||||
|
||||
$test->runCase('Ladestart wird ueber Gateway angenommen', static function (
|
||||
TestContext $test
|
||||
) use ($gateway): void {
|
||||
$antwort = json_decode(ENELIXEASEE_ProcessStationRequest(
|
||||
$gateway,
|
||||
json_encode([
|
||||
'action' => 'StartCharging',
|
||||
'serialNumber' => 'EH123456',
|
||||
], JSON_THROW_ON_ERROR)
|
||||
), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$test->assertSame(true, $antwort['success']);
|
||||
$test->assertSame(200, $antwort['httpCode']);
|
||||
});
|
||||
|
||||
$test->runCase('Gebrochene Stromvorgabe wird abgelehnt', static function (
|
||||
TestContext $test
|
||||
) use ($gateway): void {
|
||||
|
||||
@@ -95,7 +95,7 @@ return static function (TestContext $test): void {
|
||||
) use ($station): void {
|
||||
IPS_RequestAction($station, 'Aktiv', true);
|
||||
$test->assertSame(
|
||||
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
|
||||
'[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944]',
|
||||
(string) GetValue(
|
||||
$test->objectByIdent('LeistungsangebotDiagnose', $station)
|
||||
)
|
||||
@@ -108,6 +108,54 @@ return static function (TestContext $test): void {
|
||||
));
|
||||
});
|
||||
|
||||
$test->runCase('Wartezustand startet konservativ ohne aktive Phase', static function (
|
||||
TestContext $test
|
||||
) use ($sendeBeobachtung, $station): void {
|
||||
$sendeBeobachtung('EH123456', 42, true);
|
||||
$sendeBeobachtung('EH123456', 43, true);
|
||||
$sendeBeobachtung('EH123456', 96, 55);
|
||||
$sendeBeobachtung('EH123456', 102, true);
|
||||
$sendeBeobachtung('EH123456', 109, 2);
|
||||
$sendeBeobachtung('EH123456', 110, 0);
|
||||
IPS_RequestAction($station, 'Solarladen', false);
|
||||
|
||||
$test->assertSame(
|
||||
0,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $station))
|
||||
);
|
||||
$test->assertInstanceStatus($station);
|
||||
$test->assertSame(
|
||||
'[3680]',
|
||||
(string) GetValue(
|
||||
$test->objectByIdent('LeistungsangebotDiagnose', $station)
|
||||
)
|
||||
);
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue(
|
||||
$test->objectByIdent('LetzterGeraetebefehl', $station)
|
||||
),
|
||||
'"Ampere":16'
|
||||
));
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue(
|
||||
$test->objectByIdent('LetzterStartbefehl', $station)
|
||||
),
|
||||
'"Aktion":"StartCharging"'
|
||||
));
|
||||
$test->assertSame(
|
||||
55,
|
||||
GetValue($test->objectByIdent('GrundKeinLadestrom', $station))
|
||||
);
|
||||
$test->assertSame(
|
||||
true,
|
||||
GetValue($test->objectByIdent('AutorisierungErforderlich', $station))
|
||||
);
|
||||
|
||||
IPS_RequestAction($station, 'Solarladen', true);
|
||||
$sendeBeobachtung('EH123456', 110, 30);
|
||||
$sendeBeobachtung('EH123456', 109, 3);
|
||||
});
|
||||
|
||||
$test->runCase('Nur-Solar-Variante laesst Solarladen nicht abschalten', static function (
|
||||
TestContext $test
|
||||
) use ($nurSolar): void {
|
||||
@@ -136,6 +184,104 @@ return static function (TestContext $test): void {
|
||||
$test->assertInstanceStatus($station);
|
||||
});
|
||||
|
||||
$test->runCase('Solarumschaltung wartet auf die neue Manager-Vorgabe', static function (
|
||||
TestContext $test
|
||||
) use ($station, $sendeBeobachtung): void {
|
||||
$managerId = $test->createInstance(
|
||||
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||
'Hilfsmanager Easee-Uebergang',
|
||||
[
|
||||
'AutomatischeSuche' => false,
|
||||
'VerbraucherZuordnung' => json_encode([
|
||||
['InstanzID' => $station, 'Aktiv' => true],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]
|
||||
);
|
||||
$sendeSollwert = static function (int $leistung) use ($managerId, $station): void {
|
||||
IPS_RequestAction($station, 'ManagerdatenEmpfangen', json_encode([
|
||||
'Kopf' => [
|
||||
'Version' => '4.0',
|
||||
'AbsenderID' => $managerId,
|
||||
'EmpfaengerID' => $station,
|
||||
'Zeitpunkt' => time(),
|
||||
],
|
||||
'Betriebsart' => 'PV',
|
||||
'Sollleistung_W' => $leistung,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
};
|
||||
|
||||
$sendeSollwert(4104);
|
||||
$befehlVorUmschaltung = (string) GetValue(
|
||||
$test->objectByIdent('LetzterGeraetebefehl', $station)
|
||||
);
|
||||
IPS_RequestAction($station, 'Solarladen', false);
|
||||
|
||||
$test->assertSame(
|
||||
$befehlVorUmschaltung,
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $station))
|
||||
);
|
||||
$test->assertSame(
|
||||
0,
|
||||
GetValue($test->objectByIdent('Sollleistung', $station)),
|
||||
'Die verworfene Manager-Vorgabe darf nicht als aktuelle Sollleistung stehen bleiben.'
|
||||
);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $station)));
|
||||
|
||||
$sendeSollwert(10944);
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $station)),
|
||||
'"Ampere":16'
|
||||
));
|
||||
|
||||
IPS_RequestAction($station, 'Solarladen', true);
|
||||
$sendeSollwert(0);
|
||||
$test->assertSame(true, GetValue($test->objectByIdent('SollwertGueltig', $station)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $station)));
|
||||
|
||||
$sendeSollwert(10944);
|
||||
$sendeBeobachtung('EH123456', 109, 1);
|
||||
$test->assertSame('[0]', GetValue($test->objectByIdent('LeistungsangebotDiagnose', $station)));
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $station)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $station)));
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $station)),
|
||||
'"Ampere":0'
|
||||
));
|
||||
|
||||
$sendeBeobachtung('EH123456', 110, 30);
|
||||
$sendeBeobachtung('EH123456', 109, 3);
|
||||
$sendeSollwert(10944);
|
||||
IPS_RequestAction($station, 'VorgabeTimeout', 0);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $station)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $station)));
|
||||
});
|
||||
|
||||
$test->runCase('SignalR-Abbruch erreicht die Ladestation', static function (
|
||||
TestContext $test
|
||||
) use ($gateway, $station): void {
|
||||
IPS_RequestAction(
|
||||
$gateway,
|
||||
'TestSignalRFrame',
|
||||
json_encode(['type' => 7], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$test->assertInstanceStatus($gateway, 203);
|
||||
$test->assertInstanceStatus($station, 202);
|
||||
$test->assertSame(
|
||||
false,
|
||||
GetValue($test->objectByIdent('GatewayVerbunden', $station))
|
||||
);
|
||||
|
||||
IPS_RequestAction($gateway, 'TestSignalRFrame', '{}');
|
||||
|
||||
$test->assertInstanceStatus($gateway);
|
||||
$test->assertInstanceStatus($station);
|
||||
$test->assertSame(
|
||||
true,
|
||||
GetValue($test->objectByIdent('GatewayVerbunden', $station))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Gateway-Ausfall verwirft Fahrzeug- und Phasenstatus', static function (
|
||||
TestContext $test
|
||||
) use ($gateway, $station): void {
|
||||
@@ -152,4 +298,24 @@ return static function (TestContext $test): void {
|
||||
);
|
||||
$test->assertInstanceStatus($station, 202);
|
||||
});
|
||||
|
||||
$test->runCase('Leistungswerte und Fahrzeugstatus folgen ihrer Sichtbarkeitsgruppe', static function (
|
||||
TestContext $test
|
||||
) use ($station): void {
|
||||
IPS_SetProperty($station, 'EinstellungenInVisu', true);
|
||||
IPS_SetProperty($station, 'DiagnosevariablenAnzeigen', false);
|
||||
IPS_ApplyChanges($station);
|
||||
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Istleistung', $station) !== false);
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Sollleistung', $station) !== false);
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Fahrzeugstatus', $station));
|
||||
|
||||
IPS_SetProperty($station, 'EinstellungenInVisu', false);
|
||||
IPS_SetProperty($station, 'DiagnosevariablenAnzeigen', true);
|
||||
IPS_ApplyChanges($station);
|
||||
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Istleistung', $station));
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Sollleistung', $station));
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Fahrzeugstatus', $station) !== false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,8 +6,11 @@ use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$testPasswort = hash('sha256', __FILE__);
|
||||
$goEAntwort = static function (int $status, float $leistung): string {
|
||||
$goEAntwort = static function (int $status, float $leistung, array $stroeme = []): string {
|
||||
$nrg = array_fill(0, 12, 0);
|
||||
foreach (array_values($stroeme) as $index => $strom) {
|
||||
$nrg[4 + $index] = $strom;
|
||||
}
|
||||
$nrg[11] = $leistung;
|
||||
return json_encode(['car' => $status, 'nrg' => $nrg], JSON_THROW_ON_ERROR);
|
||||
};
|
||||
@@ -16,14 +19,14 @@ return static function (TestContext $test): void {
|
||||
'go-e alt' => [
|
||||
'Geraetetyp' => 1,
|
||||
'Geraeteadresse' => '192.0.2.10',
|
||||
'Testantwort' => $goEAntwort(2, 1100),
|
||||
'Testantwort' => $goEAntwort(2, 1100, [160, 158, 159]),
|
||||
'Phasenzahl' => 3,
|
||||
'BefehlUrlTeil' => 'amp=16',
|
||||
],
|
||||
'go-e Gemini' => [
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.11',
|
||||
'Testantwort' => $goEAntwort(2, 2300),
|
||||
'Testantwort' => $goEAntwort(2, 2300, [10, 0, 0]),
|
||||
'Phasenzahl' => 1,
|
||||
'BefehlUrlTeil' => 'amp=16',
|
||||
],
|
||||
@@ -47,6 +50,7 @@ return static function (TestContext $test): void {
|
||||
foreach ($definitionen as $name => $definition) {
|
||||
$properties = array_merge($definition, [
|
||||
'MaximalerLadestrom' => 16,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
@@ -61,6 +65,113 @@ return static function (TestContext $test): void {
|
||||
);
|
||||
}
|
||||
|
||||
$solarPause = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Solarpause',
|
||||
[
|
||||
'Geraetetyp' => 1,
|
||||
'Geraeteadresse' => '192.0.2.12',
|
||||
'MaximalerLadestrom' => 16,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 0),
|
||||
]
|
||||
);
|
||||
|
||||
$stromerkennung = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Fahrzeugstromerkennung',
|
||||
[
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.13',
|
||||
'MaximalerLadestrom' => 20,
|
||||
'FahrzeugstromErkennungszeit' => 1,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 10260, [15.9, 15.8, 15.7]),
|
||||
]
|
||||
);
|
||||
|
||||
$phasenerkennung = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Phasenerkennung',
|
||||
[
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.14',
|
||||
'MaximalerLadestrom' => 16,
|
||||
'Phasenerkennungszeit' => 1,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 0),
|
||||
]
|
||||
);
|
||||
|
||||
$phasenhaltung = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Phasenhaltung',
|
||||
[
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.14',
|
||||
'MaximalerLadestrom' => 16,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
|
||||
]
|
||||
);
|
||||
|
||||
$schaltsperre = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Schaltsperre',
|
||||
[
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.15',
|
||||
'MaximalerLadestrom' => 16,
|
||||
'ZeitZwischenZustandswechseln' => 1,
|
||||
'Mindesteinschaltdauer' => 2,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
|
||||
]
|
||||
);
|
||||
|
||||
$mindestzeiten = $test->createInstance(
|
||||
'{0D94913C-0F31-4C29-A685-6EB4AE58E55D}',
|
||||
'Ladestation Mindestzeiten',
|
||||
[
|
||||
'Geraetetyp' => 2,
|
||||
'Geraeteadresse' => '192.0.2.17',
|
||||
'MaximalerLadestrom' => 16,
|
||||
'ZeitZwischenZustandswechseln' => 0,
|
||||
'Mindesteinschaltdauer' => 1,
|
||||
'Mindestausschaltdauer' => 1,
|
||||
'Ladefreigabe' => true,
|
||||
'Solarladen' => true,
|
||||
'EinstellungenInVisu' => true,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
'Testmodus' => true,
|
||||
'Testantwort' => $goEAntwort(2, 10944, [16, 16, 16]),
|
||||
]
|
||||
);
|
||||
|
||||
$managerId = $test->createInstance(
|
||||
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||
'Hilfsmanager Ladestation',
|
||||
@@ -68,7 +179,16 @@ return static function (TestContext $test): void {
|
||||
'AutomatischeSuche' => false,
|
||||
'VerbraucherZuordnung' => json_encode(array_map(
|
||||
static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true],
|
||||
array_values($stationen)
|
||||
array_merge(
|
||||
array_values($stationen),
|
||||
[
|
||||
$stromerkennung,
|
||||
$phasenerkennung,
|
||||
$phasenhaltung,
|
||||
$schaltsperre,
|
||||
$mindestzeiten,
|
||||
]
|
||||
)
|
||||
), JSON_THROW_ON_ERROR),
|
||||
]
|
||||
);
|
||||
@@ -89,10 +209,25 @@ return static function (TestContext $test): void {
|
||||
'Sollleistung_W' => $leistung,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
};
|
||||
$warteAufGeraetebefehl = static function (
|
||||
int $stationId,
|
||||
string $urlTeil
|
||||
) use ($test): string {
|
||||
$deadline = microtime(true) + 3.0;
|
||||
do {
|
||||
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
|
||||
if (str_contains($befehl, $urlTeil)) {
|
||||
return $befehl;
|
||||
}
|
||||
IPS_Sleep(50);
|
||||
} while (microtime(true) < $deadline);
|
||||
|
||||
return $befehl;
|
||||
};
|
||||
|
||||
$test->runCase('Alle drei Geraetevarianten erkennen Fahrzeug und Phasen', static function (
|
||||
TestContext $test
|
||||
) use ($definitionen, $stationen): void {
|
||||
) use ($definitionen, $stationen, $warteAufGeraetebefehl): void {
|
||||
foreach ($stationen as $name => $stationId) {
|
||||
IPS_RequestAction($stationId, 'Aktiv', true);
|
||||
$test->assertSame(true, GetValue($test->objectByIdent('FahrzeugVerbunden', $stationId)));
|
||||
@@ -102,7 +237,10 @@ return static function (TestContext $test): void {
|
||||
);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('FahrzeugGeladen', $stationId)));
|
||||
IPS_RequestAction($stationId, 'Solarladen', false);
|
||||
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
|
||||
$befehl = $warteAufGeraetebefehl(
|
||||
$stationId,
|
||||
$definitionen[$name]['BefehlUrlTeil']
|
||||
);
|
||||
$diagnose = json_encode([
|
||||
'Aktiv' => GetValue($test->objectByIdent('Aktiv', $stationId)),
|
||||
'Ladefreigabe' => GetValue($test->objectByIdent('Ladefreigabe', $stationId)),
|
||||
@@ -120,12 +258,115 @@ return static function (TestContext $test): void {
|
||||
}
|
||||
});
|
||||
|
||||
$test->runCase('Erkannte Phasen bleiben waehrend einer Ladepause stabil', static function (
|
||||
TestContext $test
|
||||
) use ($goEAntwort, $phasenhaltung): void {
|
||||
IPS_RequestAction($phasenhaltung, 'Aktiv', true);
|
||||
$test->assertSame(
|
||||
3,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $phasenhaltung))
|
||||
);
|
||||
|
||||
IPS_SetProperty($phasenhaltung, 'Testantwort', $goEAntwort(2, 0));
|
||||
IPS_ApplyChanges($phasenhaltung);
|
||||
IPS_RequestAction($phasenhaltung, 'Statuszyklus', false);
|
||||
|
||||
$test->assertSame(
|
||||
3,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $phasenhaltung))
|
||||
);
|
||||
$test->assertSame(
|
||||
false,
|
||||
GetValue($test->objectByIdent('FahrzeugGeladen', $phasenhaltung))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Schaltsperre haelt die aktuelle Leistung stabil', static function (
|
||||
TestContext $test
|
||||
) use ($schaltsperre, $anforderungSenden): void {
|
||||
IPS_RequestAction($schaltsperre, 'Aktiv', true);
|
||||
IPS_RequestAction($schaltsperre, 'Solarladen', true);
|
||||
$anforderungSenden($schaltsperre, 4104);
|
||||
IPS_RequestAction($schaltsperre, 'Statuszyklus', false);
|
||||
|
||||
$diagnose = json_encode([
|
||||
'Angebot' => GetValue($test->objectByIdent('LeistungsangebotDiagnose', $schaltsperre)),
|
||||
'Verbunden' => GetValue($test->objectByIdent('FahrzeugVerbunden', $schaltsperre)),
|
||||
'Geladen' => GetValue($test->objectByIdent('FahrzeugGeladen', $schaltsperre)),
|
||||
'Phasen' => GetValue($test->objectByIdent('Phasenzahl', $schaltsperre)),
|
||||
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $schaltsperre)),
|
||||
'SollwertGueltig' => GetValue($test->objectByIdent('SollwertGueltig', $schaltsperre)),
|
||||
'Befehl' => GetValue($test->objectByIdent('LetzterGeraetebefehl', $schaltsperre)),
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$test->assertSame(
|
||||
'[4104]',
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $schaltsperre)),
|
||||
'Diagnose: ' . $diagnose
|
||||
);
|
||||
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
$anforderungSenden($schaltsperre, 4788);
|
||||
} catch (Throwable $fehler) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
if (!$abgewiesen) {
|
||||
$abgewiesen = GetValue(
|
||||
$test->objectByIdent('SollwertGueltig', $schaltsperre)
|
||||
) !== true || GetValue(
|
||||
$test->objectByIdent('Sollleistung', $schaltsperre)
|
||||
) !== 4788;
|
||||
}
|
||||
$test->assertSame(true, $abgewiesen);
|
||||
});
|
||||
|
||||
$test->runCase('Mindestzeiten erlauben Modulation aber verhindern Takten', static function (
|
||||
TestContext $test
|
||||
) use ($mindestzeiten, $anforderungSenden): void {
|
||||
IPS_RequestAction($mindestzeiten, 'Aktiv', true);
|
||||
$anforderungSenden($mindestzeiten, 4104);
|
||||
IPS_RequestAction($mindestzeiten, 'Statuszyklus', false);
|
||||
|
||||
$angebot = json_decode(
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $mindestzeiten)),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$test->assertTrue(!in_array(0, $angebot, true));
|
||||
$test->assertTrue(in_array(4788, $angebot, true));
|
||||
|
||||
$anforderungSenden($mindestzeiten, 4788);
|
||||
IPS_RequestAction($mindestzeiten, 'Ladefreigabe', false);
|
||||
IPS_RequestAction($mindestzeiten, 'Ladefreigabe', true);
|
||||
|
||||
$test->assertSame(
|
||||
'[0]',
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $mindestzeiten))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Solarpause wird nicht als Ladeende erkannt', static function (
|
||||
TestContext $test
|
||||
) use ($solarPause): void {
|
||||
IPS_RequestAction($solarPause, 'Solarladen', true);
|
||||
|
||||
$test->assertSame(
|
||||
false,
|
||||
GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause))
|
||||
);
|
||||
$test->assertSame(
|
||||
2,
|
||||
GetValue($test->objectByIdent('Fahrzeugstatus', $solarPause))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Solar- und Peakbetrieb liefern die Enelix-1-Angebote', static function (
|
||||
TestContext $test
|
||||
) use ($stationen, $anforderungSenden): void {
|
||||
$stationId = $stationen['go-e alt'];
|
||||
IPS_RequestAction($stationId, 'Solarladen', true);
|
||||
$anforderungSenden($stationId, 4150);
|
||||
$anforderungSenden($stationId, 4104);
|
||||
$befehl = (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId));
|
||||
$diagnose = json_encode([
|
||||
'Sollleistung' => GetValue($test->objectByIdent('Sollleistung', $stationId)),
|
||||
@@ -138,24 +379,36 @@ return static function (TestContext $test): void {
|
||||
'amp=6'
|
||||
), 'Solarladen: Der 6-A-Befehl fehlt. Erhalten: ' . $befehl . '; Diagnose: ' . $diagnose);
|
||||
|
||||
$befehlVorUmschaltung = $befehl;
|
||||
IPS_RequestAction($stationId, 'Solarladen', false);
|
||||
$test->assertSame(
|
||||
$befehlVorUmschaltung,
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
||||
'Die Umschaltung darf vor der neuen Manager-Vorgabe keinen Zwischenwert setzen.'
|
||||
);
|
||||
$test->assertSame(
|
||||
0,
|
||||
GetValue($test->objectByIdent('Sollleistung', $stationId)),
|
||||
'Die verworfene Manager-Vorgabe darf nicht als aktuelle Sollleistung stehen bleiben.'
|
||||
);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $stationId)));
|
||||
$angebot = (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId));
|
||||
$test->assertSame('[11000]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.');
|
||||
$test->assertSame('[10944]', $angebot, 'PV ohne Solarladen muss nur das Maximum anbieten.');
|
||||
$abgewiesen = false;
|
||||
try {
|
||||
$anforderungSenden($stationId, 4150);
|
||||
$anforderungSenden($stationId, 4104);
|
||||
} catch (Throwable $fehler) {
|
||||
$abgewiesen = true;
|
||||
}
|
||||
if (!$abgewiesen) {
|
||||
$abgewiesen = GetValue($test->objectByIdent('SollwertGueltig', $stationId)) !== true
|
||||
|| GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4150;
|
||||
|| GetValue($test->objectByIdent('Sollleistung', $stationId)) !== 4104;
|
||||
}
|
||||
$test->assertSame(true, $abgewiesen, 'Normalbetrieb muss eine Zwischenstufe ablehnen.');
|
||||
|
||||
$anforderungSenden($stationId, 4150, 'Peak');
|
||||
$anforderungSenden($stationId, 4104, 'Peak');
|
||||
$test->assertSame(
|
||||
'[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]',
|
||||
'[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944]',
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
|
||||
);
|
||||
$test->assertTrue(str_contains(
|
||||
@@ -174,4 +427,123 @@ return static function (TestContext $test): void {
|
||||
'alw=0'
|
||||
), 'Peak mit Solarladen muss die Ladestation stoppen.');
|
||||
});
|
||||
|
||||
$test->runCase('Fahrzeugstrom wird nach stabiler Unterschreitung begrenzt', static function (
|
||||
TestContext $test
|
||||
) use ($stromerkennung, $anforderungSenden): void {
|
||||
IPS_RequestAction($stromerkennung, 'Aktiv', true);
|
||||
IPS_RequestAction($stromerkennung, 'Solarladen', true);
|
||||
$anforderungSenden($stromerkennung, 13680);
|
||||
IPS_RequestAction($stromerkennung, 'Statuszyklus', false);
|
||||
IPS_Sleep(2100);
|
||||
IPS_RequestAction($stromerkennung, 'Statuszyklus', false);
|
||||
|
||||
$test->assertSame(
|
||||
18,
|
||||
GetValue($test->objectByIdent('FahrzeugMaximalstrom', $stromerkennung))
|
||||
);
|
||||
$test->assertSame(
|
||||
'[0,4104,4788,5472,6156,6840,7524,8208,8892,9576,10260,10944,11628,12312]',
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stromerkennung))
|
||||
);
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stromerkennung)),
|
||||
'amp=18'
|
||||
));
|
||||
});
|
||||
|
||||
$test->runCase('Neue Ladestation wird erst nach Freigabe mit Mindeststrom erkannt', static function (
|
||||
TestContext $test
|
||||
) use ($phasenerkennung, $anforderungSenden): void {
|
||||
$stationId = $phasenerkennung;
|
||||
$test->assertSame(
|
||||
0,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $stationId))
|
||||
);
|
||||
$test->assertTrue(!str_contains(
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
||||
'amp=6'
|
||||
), 'Eine inaktive Station darf keine Phasenprobe starten.');
|
||||
|
||||
IPS_RequestAction($stationId, 'Aktiv', true);
|
||||
|
||||
$test->assertSame(
|
||||
0,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $stationId))
|
||||
);
|
||||
$test->assertTrue(str_contains(
|
||||
(string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stationId)),
|
||||
'amp=6'
|
||||
), 'Nach der Freigabe muss die Mindeststrom-Phasenprobe starten.');
|
||||
|
||||
IPS_Sleep(2100);
|
||||
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
||||
$anforderungSenden($stationId, 3680);
|
||||
IPS_RequestAction($stationId, 'Statuszyklus', false);
|
||||
|
||||
$test->assertSame(
|
||||
1,
|
||||
GetValue($test->objectByIdent('Phasenzahl', $stationId))
|
||||
);
|
||||
$test->assertSame(
|
||||
false,
|
||||
GetValue($test->objectByIdent('FahrzeugGeladen', $stationId))
|
||||
);
|
||||
$test->assertSame(
|
||||
true,
|
||||
GetValue($test->objectByIdent('SollwertGueltig', $stationId))
|
||||
);
|
||||
$test->assertSame(
|
||||
3680,
|
||||
GetValue($test->objectByIdent('Sollleistung', $stationId))
|
||||
);
|
||||
$test->assertSame(
|
||||
'[0,1380,1610,1840,2070,2300,2530,2760,2990,3220,3450,3680]',
|
||||
(string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId))
|
||||
);
|
||||
});
|
||||
|
||||
$test->runCase('Abziehen und Vorgabe-Timeout hinterlassen keine alte Sollleistung', static function (
|
||||
TestContext $test
|
||||
) use ($stationen, $goEAntwort, $anforderungSenden): void {
|
||||
$stationId = $stationen['go-e alt'];
|
||||
IPS_SetProperty($stationId, 'Testantwort', $goEAntwort(2, 10944, [160, 160, 160]));
|
||||
IPS_ApplyChanges($stationId);
|
||||
IPS_RequestAction($stationId, 'Aktiv', true);
|
||||
IPS_RequestAction($stationId, 'Solarladen', true);
|
||||
$anforderungSenden($stationId, 10944);
|
||||
$test->assertSame(10944, GetValue($test->objectByIdent('Sollleistung', $stationId)));
|
||||
|
||||
IPS_RequestAction($stationId, 'VorgabeTimeout', 0);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $stationId)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $stationId)));
|
||||
|
||||
$anforderungSenden($stationId, 10944);
|
||||
IPS_SetProperty($stationId, 'Testantwort', $goEAntwort(1, 0));
|
||||
IPS_ApplyChanges($stationId);
|
||||
$test->assertSame('[0]', GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stationId)));
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $stationId)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $stationId)));
|
||||
});
|
||||
|
||||
$test->runCase('Leistungswerte und Fahrzeugstatus folgen ihrer Sichtbarkeitsgruppe', static function (
|
||||
TestContext $test
|
||||
) use ($stationen): void {
|
||||
$stationId = $stationen['go-e alt'];
|
||||
IPS_SetProperty($stationId, 'EinstellungenInVisu', true);
|
||||
IPS_SetProperty($stationId, 'DiagnosevariablenAnzeigen', false);
|
||||
IPS_ApplyChanges($stationId);
|
||||
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Istleistung', $stationId) !== false);
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Sollleistung', $stationId) !== false);
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Fahrzeugstatus', $stationId));
|
||||
|
||||
IPS_SetProperty($stationId, 'EinstellungenInVisu', false);
|
||||
IPS_SetProperty($stationId, 'DiagnosevariablenAnzeigen', true);
|
||||
IPS_ApplyChanges($stationId);
|
||||
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Istleistung', $stationId));
|
||||
$test->assertSame(false, @IPS_GetObjectIDByIdent('Sollleistung', $stationId));
|
||||
$test->assertTrue(@IPS_GetObjectIDByIdent('Fahrzeugstatus', $stationId) !== false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,7 +5,15 @@ declare(strict_types=1);
|
||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$netzleistungId = $test->createVariable(2, 'Netzleistung', 0.0);
|
||||
$netzleistungId = $test->createVariable(2, 'Netzleistung', -800.0);
|
||||
$netzbezugEnergieId = $test->createVariable(2, 'Netzbezug-Energie', 32.0);
|
||||
$netzeinspeisungEnergieId = $test->createVariable(2, 'Netzeinspeisung-Energie', 16.0);
|
||||
$pvLeistungId = $test->createVariable(2, 'PV-Leistung', 4200.0);
|
||||
$pvEnergieId = $test->createVariable(2, 'PV-Energie', 120.0);
|
||||
$batterieLeistungId = $test->createVariable(2, 'Batterieleistung', 700.0);
|
||||
$batterieLadeenergieId = $test->createVariable(2, 'Batterie-Ladeenergie', 18.0);
|
||||
$batterieEntladeenergieId = $test->createVariable(2, 'Batterie-Entladeenergie', 12.0);
|
||||
$batterieSocId = $test->createVariable(2, 'Batterie-Ladezustand', 62.0);
|
||||
$einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig');
|
||||
$einStufigId = $test->createInstance(
|
||||
'{15879A4E-D0C2-4495-83DE-46E1E462591E}',
|
||||
@@ -72,12 +80,55 @@ return static function (TestContext $test): void {
|
||||
['InstanzID' => $warmwasserId, 'Aktiv' => true],
|
||||
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
|
||||
];
|
||||
$wechselrichterKonfiguration = [[
|
||||
'ID' => 'hybrid-1',
|
||||
'Name' => 'Hybridwechselrichter',
|
||||
'Typ' => 'hybrid',
|
||||
'ACNennleistung_kW' => 10.0,
|
||||
'IstleistungVariableID' => $pvLeistungId,
|
||||
'Istleistungsfaktor' => 1.0,
|
||||
'ErzeugungsenergieVariableID' => $pvEnergieId,
|
||||
'Erzeugungsenergiefaktor' => 1.0,
|
||||
]];
|
||||
$pvKonfiguration = [[
|
||||
'ID' => 'pv-1',
|
||||
'Name' => 'Suedflaeche',
|
||||
'DCLeistung_kWp' => 12.0,
|
||||
'Neigung_Grad' => 30.0,
|
||||
'Azimut_Grad' => 0.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'MPPT' => 'A',
|
||||
'Modulanzahl' => 30,
|
||||
'Modulleistung_Wp' => 400.0,
|
||||
]];
|
||||
$batterieKonfiguration = [[
|
||||
'ID' => 'bat-1',
|
||||
'Name' => 'Hausspeicher',
|
||||
'Nennkapazitaet_kWh' => 13.5,
|
||||
'Nutzkapazitaet_kWh' => 12.8,
|
||||
'MaxLadeleistung_kW' => 5.0,
|
||||
'MaxEntladeleistung_kW' => 5.0,
|
||||
'WechselrichterID' => 'hybrid-1',
|
||||
'Kopplung' => 'hybrid',
|
||||
'LeistungVariableID' => $batterieLeistungId,
|
||||
'Leistungsfaktor' => 1.0,
|
||||
'SOCVariableID' => $batterieSocId,
|
||||
'LadeenergieVariableID' => $batterieLadeenergieId,
|
||||
'EntladeenergieVariableID' => $batterieEntladeenergieId,
|
||||
'Energiefaktor' => 1.0,
|
||||
]];
|
||||
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
|
||||
$managerId = $test->createInstance(
|
||||
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
|
||||
'Manager',
|
||||
[
|
||||
'NetzleistungVariableID' => $netzleistungId,
|
||||
'NetzbezugEnergieVariableID' => $netzbezugEnergieId,
|
||||
'NetzeinspeisungEnergieVariableID' => $netzeinspeisungEnergieId,
|
||||
'EnergyPieAnzeigen' => true,
|
||||
'EnergiediagrammeAnzeigen' => true,
|
||||
'FunFactsAnzeigen' => true,
|
||||
'EnergieflussAnzeigen' => true,
|
||||
'Lizenzcode' => $lizenzcode,
|
||||
'AutomatischeSuche' => false,
|
||||
'VerbraucherZuordnung' => json_encode($zuordnung, JSON_THROW_ON_ERROR),
|
||||
@@ -87,6 +138,12 @@ return static function (TestContext $test): void {
|
||||
'Lastspitzengrenze' => 0.0,
|
||||
'SollwertSolarladen' => 0.0,
|
||||
'Umschaltdifferenz' => 0.0,
|
||||
'AnlagenWechselrichter' => json_encode(
|
||||
$wechselrichterKonfiguration,
|
||||
JSON_THROW_ON_ERROR
|
||||
),
|
||||
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
|
||||
'AnlagenBatterien' => json_encode($batterieKonfiguration, JSON_THROW_ON_ERROR),
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
]
|
||||
);
|
||||
@@ -145,6 +202,326 @@ return static function (TestContext $test): void {
|
||||
|
||||
return is_array($daten) ? $daten : [];
|
||||
};
|
||||
$lizenzInstallationID = static function () use ($test, $managerId): string {
|
||||
$formular = IPS_GetConfigurationForm($managerId);
|
||||
if (preg_match(
|
||||
'/Installations-ID: ([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i',
|
||||
$formular,
|
||||
$treffer
|
||||
) !== 1) {
|
||||
$test->fail('Das Manager-Formular enthaelt keine gueltige Installations-ID.');
|
||||
}
|
||||
|
||||
return strtolower($treffer[1]);
|
||||
};
|
||||
$test->runCase('Installations-ID bleibt bei ApplyChanges stabil', static function (
|
||||
TestContext $test
|
||||
) use ($managerId, $lizenzInstallationID): void {
|
||||
$vorher = $lizenzInstallationID();
|
||||
IPS_ApplyChanges($managerId);
|
||||
$test->assertSame($vorher, $lizenzInstallationID());
|
||||
|
||||
$topologie = json_decode(
|
||||
ENELIX_AnlagentopologieExportieren($managerId),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$test->assertSame($vorher, $topologie['installationId'] ?? null);
|
||||
$test->assertSame('1.0', $topologie['version'] ?? null);
|
||||
$test->assertSame(12.0, $topologie['summary']['pvDcKwp'] ?? null);
|
||||
$test->assertSame(10.0, $topologie['summary']['pvAcKw'] ?? null);
|
||||
$test->assertSame(12.8, $topologie['summary']['usableBatteryKwh'] ?? null);
|
||||
$test->assertSame('hybrid-1', $topologie['sharedAcLimits'][0]['inverterId'] ?? null);
|
||||
$test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null);
|
||||
$test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null);
|
||||
});
|
||||
$test->runCase('Energiewerte, Archivregeln und Unterobjekte werden erzeugt', static function (
|
||||
TestContext $test
|
||||
) use ($managerId, $pvLeistungId): void {
|
||||
IPS_RequestAction($managerId, 'MesswerteAufzeichnen', true);
|
||||
$test->assertSame(
|
||||
4200.0,
|
||||
GetValue($test->objectByIdent('PVLeistungArchiv', $managerId)),
|
||||
sprintf(
|
||||
'PV-Archiv unveraendert; Quelle %.1f W, Managerstatus %d, Konfiguration %s.',
|
||||
(float) GetValue($pvLeistungId),
|
||||
(int) IPS_GetInstance($managerId)['InstanceStatus'],
|
||||
(string) IPS_GetProperty($managerId, 'AnlagenWechselrichter')
|
||||
)
|
||||
);
|
||||
$test->assertSame(
|
||||
2700.0,
|
||||
GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId))
|
||||
);
|
||||
$test->assertSame(
|
||||
-800.0,
|
||||
GetValue($test->objectByIdent('NetzleistungArchiv', $managerId))
|
||||
);
|
||||
$test->assertSame(
|
||||
700.0,
|
||||
GetValue($test->objectByIdent('BatterieleistungArchiv', $managerId))
|
||||
);
|
||||
|
||||
$archive = IPS_GetInstanceListByModuleID(
|
||||
'{43192F0B-135B-4CE7-A0A7-1475603F3060}'
|
||||
);
|
||||
$test->assertTrue($archive !== []);
|
||||
$archivID = (int) $archive[0];
|
||||
$leistungID = $test->objectByIdent('PVLeistungArchiv', $managerId);
|
||||
$energieID = $test->objectByIdent('PVEnergie', $managerId);
|
||||
$test->assertSame(true, AC_GetLoggingStatus($archivID, $leistungID));
|
||||
$test->assertSame(0, AC_GetAggregationType($archivID, $leistungID));
|
||||
$test->assertSame(1, AC_GetAggregationType($archivID, $energieID));
|
||||
|
||||
foreach (['Leistungsdiagramm', 'Energiediagramm'] as $ident) {
|
||||
$medienID = $test->objectByIdent($ident, $managerId);
|
||||
$test->assertSame(4, (int) IPS_GetMedia($medienID)['MediaType']);
|
||||
}
|
||||
$html = (string) GetValue($test->objectByIdent('EnergieFunFacts', $managerId));
|
||||
$test->assertTrue(str_contains($html, 'Solar'));
|
||||
$test->assertTrue(str_contains($html, 'Netz & Haus'));
|
||||
$energieflussID = $test->objectByIdent('Energiefluss', $managerId);
|
||||
$test->assertSame(
|
||||
'{BA90FDB3-C203-8F26-B4D6-F3DB99905724}',
|
||||
IPS_GetInstance($energieflussID)['ModuleInfo']['ModuleID']
|
||||
);
|
||||
$knoten = json_decode(
|
||||
(string) IPS_GetProperty($energieflussID, 'Config'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$namen = array_column($knoten, 'Name');
|
||||
foreach (['Verbraucher 1-Stufig', 'Wassererwärmer', 'Pufferspeicher'] as $name) {
|
||||
$test->assertTrue(in_array($name, $namen, true));
|
||||
}
|
||||
foreach ($knoten as $knotenEintrag) {
|
||||
if (in_array(
|
||||
$knotenEintrag['Name'],
|
||||
['Verbraucher 1-Stufig', 'Wassererwärmer', 'Pufferspeicher'],
|
||||
true
|
||||
)) {
|
||||
$test->assertTrue((int) $knotenEintrag['InfoVariableID'] > 1);
|
||||
}
|
||||
}
|
||||
$pvFlussID = $test->objectByIdent('EnergieflussPV', $managerId);
|
||||
$test->assertSame(
|
||||
4200.0,
|
||||
(float) GetValue($pvFlussID),
|
||||
'Der anfaengliche PV-Energiefluss ist falsch.'
|
||||
);
|
||||
$test->assertSame('~Power', IPS_GetVariable($pvFlussID)['VariableProfile']);
|
||||
|
||||
$pieID = $test->objectByIdent('EnergyPie', $managerId);
|
||||
$leistungsdiagrammID = $test->objectByIdent('Leistungsdiagramm', $managerId);
|
||||
$energiediagrammID = $test->objectByIdent('Energiediagramm', $managerId);
|
||||
$energiediagramm = json_decode(
|
||||
base64_decode((string) IPS_GetMediaContent($energiediagrammID), true),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$energietitel = array_column($energiediagramm['datasets'] ?? [], 'title');
|
||||
foreach ($energiediagramm['datasets'] ?? [] as $datensatz) {
|
||||
$test->assertSame('bar', $datensatz['type'] ?? null);
|
||||
}
|
||||
foreach ([
|
||||
'Netzbezug',
|
||||
'Netzeinspeisung',
|
||||
'Batterie laden gesamt',
|
||||
'Batterie entladen gesamt',
|
||||
] as $titel) {
|
||||
$test->assertTrue(in_array($titel, $energietitel, true));
|
||||
}
|
||||
|
||||
$angepasstesDiagramm = base64_encode(
|
||||
json_encode(['datasets' => [], 'custom' => 'bleibt'], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
IPS_SetMediaContent($energiediagrammID, $angepasstesDiagramm);
|
||||
IPS_SetProperty($pieID, 'Zaehlerfaktor', 2.0);
|
||||
IPS_ApplyChanges($pieID);
|
||||
IPS_SetProperty($energieflussID, 'Config', '[]');
|
||||
IPS_ApplyChanges($energieflussID);
|
||||
IPS_SetProperty($managerId, 'EnergieflussLeistungseinheit', 1);
|
||||
IPS_SetProperty($managerId, 'DiagrammPVModus', 2);
|
||||
IPS_SetProperty($managerId, 'DiagrammBatterieModus', 2);
|
||||
IPS_ApplyChanges($managerId);
|
||||
|
||||
$test->assertSame($pieID, $test->objectByIdent('EnergyPie', $managerId));
|
||||
$test->assertSame(
|
||||
$leistungsdiagrammID,
|
||||
$test->objectByIdent('Leistungsdiagramm', $managerId)
|
||||
);
|
||||
$test->assertSame(
|
||||
$energiediagrammID,
|
||||
$test->objectByIdent('Energiediagramm', $managerId)
|
||||
);
|
||||
$test->assertSame(
|
||||
$angepasstesDiagramm,
|
||||
(string) IPS_GetMediaContent($energiediagrammID)
|
||||
);
|
||||
$test->assertSame(2.0, (float) IPS_GetProperty($pieID, 'Zaehlerfaktor'));
|
||||
$test->assertSame('[]', (string) IPS_GetProperty($energieflussID, 'Config'));
|
||||
$test->assertSame(
|
||||
4200.0,
|
||||
(float) GetValue($pvFlussID),
|
||||
'Der bestehende PV-Energiefluss wurde beim Apply veraendert.'
|
||||
);
|
||||
$test->assertSame('~Power', IPS_GetVariable($pvFlussID)['VariableProfile']);
|
||||
|
||||
foreach ([
|
||||
'EnergyPieAnzeigen',
|
||||
'EnergiediagrammeAnzeigen',
|
||||
'FunFactsAnzeigen',
|
||||
'EnergieflussAnzeigen',
|
||||
] as $property) {
|
||||
IPS_SetProperty($managerId, $property, false);
|
||||
}
|
||||
IPS_ApplyChanges($managerId);
|
||||
foreach ([
|
||||
'EnergyPie',
|
||||
'Leistungsdiagramm',
|
||||
'Energiediagramm',
|
||||
'EnergieFunFacts',
|
||||
] as $ident) {
|
||||
$test->assertSame(
|
||||
false,
|
||||
@IPS_GetObjectIDByIdent($ident, $managerId),
|
||||
$ident . ' wurde beim Ausschalten nicht entfernt.'
|
||||
);
|
||||
}
|
||||
$test->assertSame(
|
||||
true,
|
||||
(bool) IPS_GetObject($energieflussID)['ObjectIsHidden']
|
||||
);
|
||||
|
||||
foreach ([
|
||||
'EnergyPieAnzeigen',
|
||||
'EnergiediagrammeAnzeigen',
|
||||
'EnergieflussAnzeigen',
|
||||
] as $property) {
|
||||
IPS_SetProperty($managerId, $property, true);
|
||||
}
|
||||
IPS_ApplyChanges($managerId);
|
||||
$neuerFlussID = $test->objectByIdent('Energiefluss', $managerId);
|
||||
$test->assertSame($energieflussID, $neuerFlussID);
|
||||
$test->assertSame(false, (bool) IPS_GetObject($neuerFlussID)['ObjectIsHidden']);
|
||||
$test->assertTrue((string) IPS_GetProperty($neuerFlussID, 'Config') !== '[]');
|
||||
$test->assertSame(4.2, (float) GetValue($pvFlussID));
|
||||
$test->assertSame('Enelix.Power.kW', IPS_GetVariable($pvFlussID)['VariableProfile']);
|
||||
|
||||
$neuesLeistungsdiagramm = json_decode(
|
||||
base64_decode(
|
||||
(string) IPS_GetMediaContent(
|
||||
$test->objectByIdent('Leistungsdiagramm', $managerId)
|
||||
),
|
||||
true
|
||||
),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$leistungstitel = array_column(
|
||||
$neuesLeistungsdiagramm['datasets'] ?? [],
|
||||
'title'
|
||||
);
|
||||
foreach (['PV gesamt', 'Hybridwechselrichter', 'Batterie gesamt', 'Hausspeicher'] as $titel) {
|
||||
$test->assertTrue(in_array($titel, $leistungstitel, true));
|
||||
}
|
||||
});
|
||||
|
||||
$test->runCase('SDL korrigiert Hauslast und ergaenzt bestehende Anzeigen idempotent', static function (
|
||||
TestContext $test
|
||||
) use ($managerId): void {
|
||||
$leistungID = $test->createVariable(2, 'SDL Istleistung', -1.2);
|
||||
$socID = $test->createVariable(2, 'SDL SOC', 47.5);
|
||||
$flussID = $test->objectByIdent('Energiefluss', $managerId);
|
||||
$chartID = $test->objectByIdent('Leistungsdiagramm', $managerId);
|
||||
$flussVorher = json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$chartVorher = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR);
|
||||
try {
|
||||
IPS_SetProperty($managerId, 'SDLAktiv', true);
|
||||
IPS_SetProperty($managerId, 'SDLLeistungVariableID', $leistungID);
|
||||
IPS_SetProperty($managerId, 'SDLLeistungsfaktor', -1000.0);
|
||||
IPS_SetProperty($managerId, 'SDLSOCVariableID', $socID);
|
||||
IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', true);
|
||||
IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', true);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$test->assertSame(1500.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId)));
|
||||
$test->assertSame(1200.0, GetValue($test->objectByIdent('SDLLeistungArchiv', $managerId)));
|
||||
$test->assertSame(47.5, GetValue($test->objectByIdent('SDLSOCArchiv', $managerId)));
|
||||
$test->assertSame(1.2, GetValue($test->objectByIdent('EnergieflussSDL', $managerId)));
|
||||
$test->assertSame('Aktiv', GetValue($test->objectByIdent('SDLStatus', $managerId)));
|
||||
$fluss = json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$test->assertSame($flussVorher, array_slice($fluss, 0, count($flussVorher)));
|
||||
$test->assertSame(count($flussVorher) + 1, count($fluss));
|
||||
$test->assertSame($test->objectByIdent('SDLSOCArchiv', $managerId), $fluss[count($fluss) - 1]['InfoVariableID']);
|
||||
$chart = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR);
|
||||
$test->assertSame($chartVorher['datasets'], array_slice($chart['datasets'], 0, count($chartVorher['datasets'])));
|
||||
$test->assertSame('right', $chart['datasets'][count($chart['datasets']) - 1]['side']);
|
||||
$test->assertSame('~Intensity.100', $chart['datasets'][count($chart['datasets']) - 1]['profile']);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$test->assertSame($fluss, json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR));
|
||||
$test->assertSame($chart, json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR));
|
||||
|
||||
foreach ([0 => ['~Power', 1200.0], 1 => ['Enelix.Power.kW', 1.2]] as $einheit => $erwartet) {
|
||||
IPS_SetProperty($managerId, 'EnergieflussAnzeigen', false);
|
||||
IPS_ApplyChanges($managerId);
|
||||
IPS_SetProperty($managerId, 'EnergieflussLeistungseinheit', $einheit);
|
||||
IPS_SetProperty($managerId, 'EnergieflussAnzeigen', true);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$flussID = $test->objectByIdent('Energiefluss', $managerId);
|
||||
$sdlID = $test->objectByIdent('EnergieflussSDL', $managerId);
|
||||
$test->assertSame($erwartet[0], IPS_GetVariable($sdlID)['VariableProfile']);
|
||||
$test->assertSame($erwartet[1], GetValue($sdlID));
|
||||
}
|
||||
|
||||
IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', false);
|
||||
IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', false);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$test->assertSame($flussVorher, json_decode(IPS_GetProperty($flussID, 'Config'), true, 512, JSON_THROW_ON_ERROR));
|
||||
$test->assertSame($chartVorher, json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR));
|
||||
$test->assertSame(1500.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId)));
|
||||
$test->assertEquals(1.9, GetValue($test->objectByIdent('EnergieflussBatterie', $managerId)), 0.000001);
|
||||
|
||||
IPS_SetProperty($managerId, 'SDLSOCVariableID', 0);
|
||||
IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', true);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$chart = json_decode(base64_decode(IPS_GetMediaContent($chartID)), true, 512, JSON_THROW_ON_ERROR);
|
||||
$test->assertSame(count($chartVorher['datasets']) + 1, count($chart['datasets']));
|
||||
IPS_SetProperty($managerId, 'EnergieaufzeichnungAktiv', false);
|
||||
$test->assertTrue(IPS_ApplyChanges($managerId));
|
||||
$archivID = IPS_GetInstanceListByModuleID('{43192F0B-135B-4CE7-A0A7-1475603F3060}')[0];
|
||||
foreach (['SDLLeistungArchiv', 'SDLSOCArchiv', 'SDLLadenEnergie', 'SDLEntladenEnergie'] as $ident) {
|
||||
$test->assertSame(false, AC_GetLoggingStatus($archivID, $test->objectByIdent($ident, $managerId)));
|
||||
}
|
||||
} finally {
|
||||
IPS_SetProperty($managerId, 'EnergieaufzeichnungAktiv', true);
|
||||
IPS_SetProperty($managerId, 'SDLAktiv', false);
|
||||
IPS_SetProperty($managerId, 'SDLEnergieflussAnzeigen', false);
|
||||
IPS_SetProperty($managerId, 'SDLDiagrammeAnzeigen', false);
|
||||
IPS_ApplyChanges($managerId);
|
||||
}
|
||||
$test->assertSame(2700.0, GetValue($test->objectByIdent('HausverbrauchLeistungArchiv', $managerId)));
|
||||
});
|
||||
$test->runCase('SDL weist fehlende und doppelte Leistungsquellen zurueck', static function (
|
||||
TestContext $test
|
||||
) use ($managerId, $batterieLeistungId): void {
|
||||
try {
|
||||
IPS_SetProperty($managerId, 'SDLAktiv', true);
|
||||
foreach ([0, $batterieLeistungId] as $quelle) {
|
||||
IPS_SetProperty($managerId, 'SDLLeistungVariableID', $quelle);
|
||||
IPS_ApplyChanges($managerId);
|
||||
$test->assertInstanceStatus($managerId, 202);
|
||||
}
|
||||
} finally {
|
||||
IPS_SetProperty($managerId, 'SDLAktiv', false);
|
||||
IPS_SetProperty($managerId, 'SDLLeistungVariableID', 0);
|
||||
IPS_ApplyChanges($managerId);
|
||||
}
|
||||
});
|
||||
|
||||
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
|
||||
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {
|
||||
if (($verbraucher['InstanzID'] ?? 0) !== $instanzId) {
|
||||
@@ -163,7 +540,7 @@ return static function (TestContext $test): void {
|
||||
if ($lizenzcode === '') {
|
||||
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
|
||||
TestContext $test
|
||||
) use ($managerId): void {
|
||||
) use ($managerId, $verbraucherstatus): void {
|
||||
IPS_RequestAction($managerId, 'Aktiv', true);
|
||||
IPS_RequestAction($managerId, 'Regeln', true);
|
||||
$test->assertInstanceStatus($managerId, 203);
|
||||
@@ -171,6 +548,21 @@ return static function (TestContext $test): void {
|
||||
'Inaktiv',
|
||||
GetValue($test->objectByIdent('Betriebsart', $managerId))
|
||||
);
|
||||
|
||||
$daten = $verbraucherstatus();
|
||||
$test->assertSame(false, $daten['Synchronisiert'] ?? null);
|
||||
$test->assertSame(3, count($daten['NichtGeregelt'] ?? []));
|
||||
foreach ($daten['NichtGeregelt'] as $detail) {
|
||||
$test->assertTrue(is_int($detail['InstanzID'] ?? null));
|
||||
$test->assertTrue(is_string($detail['Name'] ?? null));
|
||||
$test->assertTrue(is_string($detail['Typ'] ?? null));
|
||||
$test->assertSame('Lizenz nicht freigegeben', $detail['Grund'] ?? null);
|
||||
}
|
||||
$detailtext = (string) GetValue(
|
||||
$test->objectByIdent('NichtGeregelteGeraete', $managerId)
|
||||
);
|
||||
$test->assertTrue(str_contains($detailtext, 'Boiler'));
|
||||
$test->assertTrue(str_contains($detailtext, 'Lizenz nicht freigegeben'));
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -248,12 +640,10 @@ return static function (TestContext $test): void {
|
||||
|
||||
SetValue($temperaturId, 53.0);
|
||||
$test->waitUntil(static function () use (
|
||||
$managerId,
|
||||
$warmwasserId,
|
||||
$verbraucherstatus,
|
||||
$zustandswert
|
||||
): bool {
|
||||
IPS_RequestAction($managerId, 'Regeln', false);
|
||||
$wert = $zustandswert(
|
||||
$verbraucherstatus(),
|
||||
$warmwasserId,
|
||||
@@ -276,4 +666,37 @@ return static function (TestContext $test): void {
|
||||
$test->objectByIdent('Sammelstoerung', $managerId)
|
||||
));
|
||||
});
|
||||
|
||||
$test->runCase('Veraltete Verbraucher werden aktiv neu angefordert', static function (
|
||||
TestContext $test
|
||||
) use (
|
||||
$managerId,
|
||||
$netzleistungId,
|
||||
$warmwasserId,
|
||||
$pufferspeicherId,
|
||||
$melden,
|
||||
$verbraucherstatus
|
||||
): void {
|
||||
IPS_SetProperty($managerId, 'VerbraucherTimeout', 1);
|
||||
IPS_ApplyChanges($managerId);
|
||||
SetValue($netzleistungId, -2400.0);
|
||||
$melden();
|
||||
IPS_RequestAction($managerId, 'Regeln', true);
|
||||
$test->waitUntil(
|
||||
static fn (): bool => ($verbraucherstatus()['Synchronisiert'] ?? false) === true
|
||||
);
|
||||
|
||||
IPS_Sleep(1100);
|
||||
IPS_RequestAction($warmwasserId, 'Melden', true);
|
||||
IPS_RequestAction($pufferspeicherId, 'Melden', true);
|
||||
IPS_RequestAction($managerId, 'Regeln', true);
|
||||
|
||||
$daten = $verbraucherstatus();
|
||||
$test->assertSame(true, $daten['Synchronisiert'] ?? null);
|
||||
$test->assertSame(3, count($daten['Verbraucher'] ?? []));
|
||||
$test->assertSame(0, count($daten['NichtGeregelt'] ?? []));
|
||||
$test->assertTrue(
|
||||
(float) GetValue($test->objectByIdent('Verteilbudget', $managerId)) > 0.0
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ return static function (TestContext $test): void {
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('Heizbedarf', $verbraucherId)));
|
||||
|
||||
SetValue($pufferId, 55.0);
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $verbraucherId)));
|
||||
IPS_RequestAction($verbraucherId, 'Melden', true);
|
||||
$test->assertSame([false, false], $kontaktzustand());
|
||||
});
|
||||
|
||||
@@ -225,4 +225,18 @@ return static function (TestContext $test): void {
|
||||
) === false);
|
||||
$test->assertInstanceStatus($verbraucherId);
|
||||
});
|
||||
|
||||
$test->runCase('Deaktivierung verwirft eine positive Vorgabe vollstaendig', static function (
|
||||
TestContext $test
|
||||
) use ($verbraucherId, $anforderungSenden): void {
|
||||
IPS_SetProperty($verbraucherId, 'Mindestlaufzeit', 0);
|
||||
IPS_SetProperty($verbraucherId, 'Mindesteinschaltdauer', 0);
|
||||
IPS_SetProperty($verbraucherId, 'Mindestausschaltdauer', 0);
|
||||
IPS_ApplyChanges($verbraucherId);
|
||||
$anforderungSenden(1200);
|
||||
$test->assertSame(1200, GetValue($test->objectByIdent('Sollleistung', $verbraucherId)));
|
||||
IPS_RequestAction($verbraucherId, 'Aktiv', false);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $verbraucherId)));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,9 +20,9 @@ return static function (TestContext $test): void {
|
||||
'IstleistungVariableID' => $rueckmeldung,
|
||||
'Anlaufwartezeit' => 1,
|
||||
'Wiederholsperre' => 3,
|
||||
'Mindestlaufzeit' => 1,
|
||||
'Mindestlaufzeit' => 5,
|
||||
'Mindestsperrzeit' => 0,
|
||||
'MaximaleSperrzeit' => 2,
|
||||
'MaximaleSperrzeit' => 3,
|
||||
'MaximaleSperrzeit24h' => 60,
|
||||
'VorgabeTimeout' => 30,
|
||||
'DiagnosevariablenAnzeigen' => true,
|
||||
@@ -115,7 +115,22 @@ return static function (TestContext $test): void {
|
||||
$test->assertSame(5000, (int) GetValue(
|
||||
$test->objectByIdent('Istleistung', $waermepumpe)
|
||||
));
|
||||
IPS_Sleep(1100);
|
||||
});
|
||||
|
||||
$test->runCase('Lauf ohne Vorgabe erzwingt keine Erhoehung', static function (
|
||||
TestContext $test
|
||||
) use ($waermepumpe, $kontakt1, $kontakt2): void {
|
||||
IPS_RequestAction($waermepumpe, 'Aktiv', false);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $waermepumpe)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $waermepumpe)));
|
||||
IPS_RequestAction($waermepumpe, 'Aktiv', true);
|
||||
$test->waitUntil(
|
||||
static fn (): bool => GetValue($kontakt1) === false
|
||||
&& GetValue($kontakt2) === false
|
||||
);
|
||||
$test->assertSame(false, GetValue($kontakt1));
|
||||
$test->assertSame(false, GetValue($kontakt2));
|
||||
IPS_Sleep(5100);
|
||||
});
|
||||
|
||||
$test->runCase('Peak sperrt und maximale Sperrzeit erzwingt Normalbetrieb', static function (
|
||||
@@ -123,13 +138,16 @@ return static function (TestContext $test): void {
|
||||
) use ($waermepumpe, $kontakt1, $kontakt2, $senden): void {
|
||||
$senden(null, 'Peak');
|
||||
$senden(0, 'Peak');
|
||||
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === true);
|
||||
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === true, 2500);
|
||||
$test->assertSame(false, GetValue($kontakt2));
|
||||
$test->assertSame(true, GetValue(
|
||||
$test->objectByIdent('SperreAktiv', $waermepumpe)
|
||||
));
|
||||
|
||||
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 2500);
|
||||
$test->waitUntil(static fn (): bool => GetValue($kontakt1) === false, 3500);
|
||||
$test->waitUntil(static fn (): bool => GetValue(
|
||||
$test->objectByIdent('SperrerholungAktiv', $waermepumpe)
|
||||
) === true);
|
||||
$test->assertSame(true, GetValue(
|
||||
$test->objectByIdent('SperrerholungAktiv', $waermepumpe)
|
||||
));
|
||||
|
||||
@@ -31,6 +31,8 @@ return static function (TestContext $test): void {
|
||||
'LastwechselSperrzeit' => 1,
|
||||
'Hysterese' => 5.0,
|
||||
'TemperaturMaxAlter' => 120,
|
||||
'TemperaturUntergrenze' => 0.0,
|
||||
'TemperaturObergrenze' => 100.0,
|
||||
'LegionellenfunktionAktiv' => true,
|
||||
'LegionellenMinimalintervallTage' => 4,
|
||||
'LegionellenMaximalintervallTage' => 7,
|
||||
@@ -90,6 +92,51 @@ return static function (TestContext $test): void {
|
||||
IPS_RequestAction($managerId, 'Regeln', true);
|
||||
};
|
||||
|
||||
$test->runCase('Temperaturgrenzen melden Unter- und Uebertemperatur', static function (
|
||||
TestContext $test
|
||||
) use ($verbraucherId, $temperaturSetzen): void {
|
||||
$stoertextId = $test->objectByIdent('Stoertext', $verbraucherId);
|
||||
|
||||
$temperaturSetzen(100.0);
|
||||
$test->assertTrue(!str_contains((string) GetValue($stoertextId), 'Uebertemperatur'));
|
||||
$temperaturSetzen(100.1);
|
||||
$test->assertTrue(str_contains((string) GetValue($stoertextId), 'Uebertemperatur'));
|
||||
|
||||
$temperaturSetzen(0.0);
|
||||
$test->assertTrue(!str_contains((string) GetValue($stoertextId), 'Untertemperatur'));
|
||||
$temperaturSetzen(-0.1);
|
||||
$test->assertTrue(str_contains((string) GetValue($stoertextId), 'Untertemperatur'));
|
||||
|
||||
$temperaturSetzen(52.0);
|
||||
});
|
||||
|
||||
$test->runCase('Abgelaufene Vorgabe wird auch in der Anzeige verworfen', static function (
|
||||
TestContext $test
|
||||
) use ($verbraucherId, $managerId, $kontakte): void {
|
||||
IPS_SetProperty($verbraucherId, 'VorgabeTimeout', 1);
|
||||
IPS_ApplyChanges($verbraucherId);
|
||||
IPS_RequestAction($verbraucherId, 'Aktiv', true);
|
||||
IPS_RequestAction($verbraucherId, 'ManagerdatenEmpfangen', json_encode([
|
||||
'Kopf' => [
|
||||
'Version' => '4.0',
|
||||
'AbsenderID' => $managerId,
|
||||
'EmpfaengerID' => $verbraucherId,
|
||||
'Zeitpunkt' => time(),
|
||||
],
|
||||
'Betriebsart' => 'PV',
|
||||
'Sollleistung_W' => 1000,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$test->assertSame(1000, GetValue($test->objectByIdent('Sollleistung', $verbraucherId)));
|
||||
IPS_Sleep(2100);
|
||||
IPS_RequestAction($verbraucherId, 'Melden', true);
|
||||
$test->assertSame(false, GetValue($test->objectByIdent('SollwertGueltig', $verbraucherId)));
|
||||
$test->assertSame(0, GetValue($test->objectByIdent('Sollleistung', $verbraucherId)));
|
||||
$test->waitUntil(static fn (): bool => GetValue($kontakte[0]) === false);
|
||||
IPS_RequestAction($verbraucherId, 'Aktiv', false);
|
||||
IPS_SetProperty($verbraucherId, 'VorgabeTimeout', 120);
|
||||
IPS_ApplyChanges($verbraucherId);
|
||||
});
|
||||
|
||||
if ($lizenzcode === '') {
|
||||
$test->runCase('Manager bleibt ohne Lizenz sicher gesperrt', static function (
|
||||
TestContext $test
|
||||
|
||||
@@ -9,6 +9,86 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class VerbraucherBasisTraitTest extends TestCase
|
||||
{
|
||||
/** @dataProvider sollwerte */
|
||||
public function testVerwerfenLoeschtGespeicherteUndSichtbareVorgabe(int $leistung, bool $sichtbar): void
|
||||
{
|
||||
$modul = new VerbraucherBasisDummy();
|
||||
$modul->zustand = ['Sollleistung' => $leistung, 'SollwertGueltig' => true];
|
||||
$modul->sichtbareWerte = $sichtbar ? $modul->zustand : [];
|
||||
|
||||
$modul->verwerfen();
|
||||
$modul->verwerfen();
|
||||
|
||||
self::assertSame(['Sollleistung' => 0, 'SollwertGueltig' => false], $modul->zustand);
|
||||
self::assertSame($sichtbar ? $modul->zustand : [], $modul->sichtbareWerte);
|
||||
}
|
||||
|
||||
/** @dataProvider sollwerte */
|
||||
public function testUpdateBereinigtAlteUngueltigeVorgaben(int $leistung, bool $sichtbar): void
|
||||
{
|
||||
$modul = new VerbraucherBasisDummy();
|
||||
$modul->zustand = ['Sollleistung' => $leistung, 'SollwertGueltig' => false];
|
||||
$modul->sichtbareWerte = $sichtbar ? $modul->zustand : [];
|
||||
|
||||
$modul->bereinigen();
|
||||
|
||||
self::assertSame(['Sollleistung' => 0, 'SollwertGueltig' => false], $modul->zustand);
|
||||
self::assertSame($sichtbar ? $modul->zustand : [], $modul->sichtbareWerte);
|
||||
}
|
||||
|
||||
/** @dataProvider sollwerte */
|
||||
public function testUpdateErhaeltGueltigeVorgabenAuchNullUndEntladen(int $leistung, bool $sichtbar): void
|
||||
{
|
||||
$modul = new VerbraucherBasisDummy();
|
||||
$modul->zustand = ['Sollleistung' => $leistung, 'SollwertGueltig' => true];
|
||||
$modul->sichtbareWerte = $sichtbar ? $modul->zustand : [];
|
||||
$vorher = $modul->zustand;
|
||||
|
||||
$modul->bereinigen();
|
||||
|
||||
self::assertSame($vorher, $modul->zustand);
|
||||
self::assertSame($sichtbar ? $vorher : [], $modul->sichtbareWerte);
|
||||
}
|
||||
|
||||
public static function sollwerte(): array
|
||||
{
|
||||
return [[10944, true], [10944, false], [-843, true], [-843, false], [0, true], [0, false]];
|
||||
}
|
||||
|
||||
public function testWiederholtesVerwerfenLaesstNeuBerechneteLokaleSchutzleistungStehen(): void
|
||||
{
|
||||
$modul = new VerbraucherBasisDummy();
|
||||
$modul->zustand = ['Sollleistung' => 3000, 'SollwertGueltig' => false];
|
||||
$modul->sichtbareWerte = $modul->zustand;
|
||||
|
||||
$modul->verwerfen();
|
||||
|
||||
self::assertSame(['Sollleistung' => 3000, 'SollwertGueltig' => false], $modul->zustand);
|
||||
self::assertSame($modul->zustand, $modul->sichtbareWerte);
|
||||
}
|
||||
|
||||
public function testAlleVerbraucherVerwerfenSollwerteUeberDieGemeinsameFunktion(): void
|
||||
{
|
||||
$anzahl = 0;
|
||||
foreach (glob(__DIR__ . '/../*/module.php') as $datei) {
|
||||
$quelle = (string) file_get_contents($datei);
|
||||
if (!str_contains($quelle, 'implements VerbraucherSchnittstelle')) {
|
||||
continue;
|
||||
}
|
||||
++$anzahl;
|
||||
self::assertStringContainsString('use VerbraucherBasisTrait;', $quelle, $datei);
|
||||
self::assertStringContainsString('$this->verwerfeSollwert();', $quelle, $datei);
|
||||
self::assertStringNotContainsString("setzeZustand('SollwertGueltig', false)", $quelle, $datei);
|
||||
self::assertStringNotContainsString("WriteAttributeBoolean('ZustandSollwertGueltig', false)", $quelle, $datei);
|
||||
self::assertStringContainsString(
|
||||
'$this->aktualisiereVariablen();' . "\n " . '$this->bereinigeUngueltigenSollwert();',
|
||||
$quelle,
|
||||
$datei
|
||||
);
|
||||
}
|
||||
self::assertSame(7, $anzahl);
|
||||
}
|
||||
|
||||
public function testGemeinsamePropertiesUndVariablenWerdenRegistriert(): void
|
||||
{
|
||||
$modul = new VerbraucherBasisDummy();
|
||||
@@ -50,6 +130,35 @@ final class VerbraucherBasisDummy
|
||||
{
|
||||
use VerbraucherBasisTrait;
|
||||
|
||||
/** @var array<string, int|bool> */
|
||||
public array $zustand = [];
|
||||
|
||||
/** @var array<string, int|bool> */
|
||||
public array $sichtbareWerte = [];
|
||||
|
||||
public function verwerfen(): void
|
||||
{
|
||||
$this->verwerfeSollwert();
|
||||
}
|
||||
|
||||
public function bereinigen(): void
|
||||
{
|
||||
$this->bereinigeUngueltigenSollwert();
|
||||
}
|
||||
|
||||
private function setzeZustand(string $ident, $wert): void
|
||||
{
|
||||
$this->zustand[$ident] = $wert;
|
||||
if (array_key_exists($ident, $this->sichtbareWerte)) {
|
||||
$this->sichtbareWerte[$ident] = $wert;
|
||||
}
|
||||
}
|
||||
|
||||
private function leseZustand(string $ident)
|
||||
{
|
||||
return $this->zustand[$ident];
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $properties = [];
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ final class WarmwassererwaermerModulstrukturTest extends TestCase
|
||||
self::assertMatchesRegularExpression('/^\{[0-9A-F-]{36}\}$/', $metadaten['id']);
|
||||
}
|
||||
|
||||
public function testAlle19PropertiesSindRegistriertUndImFormularDokumentiert(): void
|
||||
public function testAlle21PropertiesSindRegistriertUndImFormularDokumentiert(): void
|
||||
{
|
||||
$basis = (string) file_get_contents(__DIR__ . '/../libs/VerbraucherBasisTrait.php');
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
preg_match_all('/RegisterProperty(?:Integer|Float|Boolean|String)\(\'([^\']+)\'/', $basis . $modul, $treffer);
|
||||
$properties = array_values(array_unique($treffer[1]));
|
||||
|
||||
self::assertCount(19, $properties);
|
||||
self::assertCount(21, $properties);
|
||||
|
||||
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
|
||||
foreach ($properties as $property) {
|
||||
@@ -136,6 +136,38 @@ final class WarmwassererwaermerModulstrukturTest extends TestCase
|
||||
self::assertStringContainsString('Mindesttemperatur < Maximaltemperatur', $inhalt);
|
||||
}
|
||||
|
||||
public function testTemperaturstoergrenzenSindKonfigurierbarUndVonSollwertenGetrennt(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyFloat('TemperaturUntergrenze', 0.0)",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"RegisterPropertyFloat('TemperaturObergrenze', 100.0)",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString("ReadPropertyFloat('TemperaturUntergrenze')", $inhalt);
|
||||
self::assertStringContainsString("ReadPropertyFloat('TemperaturObergrenze')", $inhalt);
|
||||
self::assertStringContainsString("'Kennung' => 'Untertemperatur'", $inhalt);
|
||||
self::assertStringContainsString("'Kennung' => 'Uebertemperatur'", $inhalt);
|
||||
|
||||
$formular = json_decode(
|
||||
(string) file_get_contents(self::MODULPFAD . 'form.json'),
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
$erweitert = array_values(array_filter(
|
||||
$formular['elements'],
|
||||
static fn (array $element): bool =>
|
||||
$element['caption'] === 'Erweiterte Speichereinstellungen'
|
||||
));
|
||||
self::assertCount(1, $erweitert);
|
||||
self::assertContains('TemperaturUntergrenze', array_column($erweitert[0]['items'], 'name'));
|
||||
self::assertContains('TemperaturObergrenze', array_column($erweitert[0]['items'], 'name'));
|
||||
}
|
||||
|
||||
public function testSchaltfolgeIstBreakBeforeMake(): void
|
||||
{
|
||||
$inhalt = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
|
||||
Reference in New Issue
Block a user