58 Commits
Author SHA1 Message Date
dh 5437f3f058 fix(verbraucher): clear discarded power setpoints
Tests / test (push) Failing after 51s
2026-10-06 13:45:54 +00:00
dh ebec355c96 feat(manager): account for SDL power and SOC separately
Tests / test (push) Failing after 1m12s
Add opt-in SDL measurement sources and independent visualization toggles. Exclude balancing power from household telemetry and new household energy. Preserve custom views and cover measurement, migration and Symcon 8.0 behavior.
2026-10-06 13:35:17 +00:00
dh 58f91e4319 feat(manager): distribute equal-priority power stepwise
Tests / test (push) Successful in 44s
2026-10-06 11:55:26 +00:00
dh ec7182b833 fix(charger): stabilize Go-E setpoint control
Tests / test (push) Successful in 47s
2026-10-06 11:33:59 +00:00
dh 37addf4242 feat(manager): Energievisualisierung und Synchronisierung erweitern
Tests / test (push) Successful in 50s
2026-10-06 08:00:30 +00:00
dh 8d83b083bf feat(batterie): Nachladeleistung begrenzen
Tests / test (push) Successful in 48s
2026-10-01 15:56:23 +00:00
dh 6cd7bf19f5 test(ladestation): Regeluebergang abwarten 2026-10-01 15:56:14 +00:00
dh 3ce9af4244 fix(ladestation): Regeluebergaenge stabilisieren
Tests / test (push) Successful in 50s
2026-10-01 13:01:32 +00:00
dh 8357e8dfab merge: vorbereiteten Fahrplan-Commit uebernehmen
Tests / test (push) Successful in 54s
2026-10-01 12:34:51 +00:00
dh 38353aed93 feat(manager): Fahrplanvorgabe linear interpolieren
Tests / test (push) Successful in 54s
2026-10-01 12:16:10 +00:00
dh cf92e7623b fix(manager): Fahrplanvorgabe linear interpolieren 2026-09-30 14:38:42 +02:00
dh add1077674 fix(manager): Batterie nach Prioritaet als Restverbraucher regeln
Tests / test (push) Canceled after 0s
2026-09-30 05:42:13 +00:00
dh bfdcf4536b fix(ladestation): Steuerbefehle in Diagnose erhalten
Tests / test (push) Canceled after 0s
2026-09-30 04:37:33 +00:00
dh faa5dfb33f feat(ladestation): Phasenerkennung und Schaltsperren erweitern
Tests / test (push) Successful in 45s
2026-09-29 22:49:26 +00:00
dh 53fca5e2bb feat(manager): Batteriesollwert aus Netzfahrplan anwenden
Tests / test (push) Successful in 47s
2026-09-29 17:23:25 +00:00
dh f611da975d fix(easee): Ereignisverbindung konsistent neu aufbauen
Tests / test (push) Successful in 1m2s
2026-09-29 16:47:03 +00:00
dh 393969594e fix(manager): Regelung und Energiefluss korrigieren
Tests / test (push) Successful in 48s
2026-09-29 16:17:49 +00:00
dh cae14cf399 fix(manager): Festlasten nach Istleistung bilanzieren
Tests / test (push) Successful in 52s
2026-09-29 15:11:14 +00:00
dh 3aaa744a8a fix(manager): Batterie als neutralen Restregler behandeln
Tests / test (push) Successful in 51s
2026-09-29 14:38:14 +00:00
dh fdc2007bb1 fix(manager): Energieausgleich aus Enelix 1 wiederherstellen
Tests / test (push) Successful in 54s
2026-09-29 14:14:25 +00:00
dh 37400878b6 fix(easee): veraltete Ladeleistung nach Ladestopp nullen
Tests / test (push) Successful in 50s
2026-09-29 13:15:43 +00:00
dh ace1821213 fix(ladestation): Fahrzeugstrom nach Lernzeit begrenzen
Tests / test (push) Successful in 56s
2026-09-29 12:47:23 +00:00
dh b3b868cd2a fix(ladestation): Ladeanlauf und Easee-Verbindung stabilisieren
Tests / test (push) Successful in 1m23s
2026-09-29 12:25:35 +00:00
dh a74f14aa19 feat(warmwasser): Temperaturgrenzen konfigurierbar machen
Tests / test (push) Successful in 1m10s
2026-09-29 12:11:43 +00:00
dh fe966516b5 test: integrierte Freigabepruefungen stabilisieren
Tests / test (push) Successful in 1m14s
2026-09-29 12:06:51 +00:00
dh b3bbae1a72 fix(manager): Anlagentopologie immer synchronisieren
Tests / test (push) Failing after 50s
2026-09-29 11:51:55 +00:00
dh ae2cd8ecff Merge remote-tracking branch 'origin/develop' into develop
Tests / test (push) Successful in 46s
2026-09-29 11:43:12 +00:00
dh 995b704e36 fix(manager): Prognoseberechtigungen verständlich diagnostizieren
Tests / test (push) Successful in 49s
2026-09-29 11:40:24 +00:00
dh 5ff0d405d4 fix(manager): feste Leistungsangebote immer zuteilen 2026-09-29 11:40:24 +00:00
dh ccecc437e9 fix(easee): wartende Ladevorgaenge starten
Tests / test (push) Successful in 45s
2026-09-29 09:34:37 +00:00
dh 156591a91e fix(ladestation): Ladeende nach Update neu prüfen
Tests / test (push) Successful in 47s
2026-09-29 09:19:56 +00:00
dh e0814a0c14 fix(ladestation): Ladeende nach Update neu prüfen 2026-09-29 09:19:39 +00:00
dh 3cabf597ac fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:56 +00:00
dh b86810d30f fix(ladestation): Solarpause nicht als Ladeende erkennen 2026-09-29 09:07:44 +00:00
dh d1fc62d43c fix(manager): Regelung bei veralteten Verbrauchern fortsetzen
Tests / test (push) Successful in 46s
2026-09-29 08:46:44 +00:00
dh 21577c0125 fix(manager): Prognoseberechtigungen verständlich diagnostizieren 2026-09-29 08:41:32 +00:00
dh aefe46352f fix(easee): Wartezustand ohne Ausgangsphase zulassen
Tests / test (push) Successful in 48s
2026-09-29 08:25:08 +00:00
dh 6f76e6cd86 fix(easee): Initialstatus der Ladestation laden
Tests / test (push) Successful in 48s
2026-09-29 07:55:31 +00:00
dh 18c34b32e7 chore(release): erste Beta vorbereiten
Tests / test (push) Successful in 52s
2026-09-29 06:25:26 +00:00
dh b111a5c501 Ladefreigabe für Ladestationen standardmäßig aktivieren
Tests / test (push) Successful in 55s
2026-09-29 06:24:26 +00:00
dh ef708564d8 feat: Wärmepumpenlizenz separat freischalten
Tests / test (push) Successful in 47s
2026-09-29 05:53:15 +00:00
dh cf2ce3aff4 feat(manager): Lizenzierung und Energietopologie verfeinern
Tests / test (push) Successful in 46s
2026-09-28 17:47:22 +00:00
dh 708a8ecea2 feat(manager): Energieaufzeichnung und Visualisierungen integrieren
Tests / test (push) Successful in 47s
2026-09-27 19:29:37 +00:00
dh 40f41a2fb8 fix(manager): Störungszugang zuverlässig aktivieren
Tests / test (push) Successful in 52s
2026-09-27 16:39:15 +00:00
dh 2d24ce0900 feat(manager): Störungsüberwachung ans Portal anbinden
Tests / test (push) Successful in 47s
2026-09-27 15:41:15 +00:00
dh f47c908798 feat(batterie): stufenlose Leistungsbereiche unterstützen
Tests / test (push) Successful in 49s
2026-09-27 08:46:17 +00:00
dh 72d96aeb81 feat: Einspeisebegrenzung in Demo simulieren
Tests / test (push) Successful in 46s
2026-09-26 10:09:10 +00:00
dh dcf51bd8be feat: anlagenweite Einspeisebegrenzung und Netzfahrplan
Tests / test (push) Successful in 50s
2026-09-26 09:09:08 +00:00
dh efa8fe698c feat: Prognosetelemetrie aus Demoanlage senden
Tests / test (push) Successful in 48s
2026-09-23 06:40:17 +00:00
dh f250dea59a feat: Demoanlage an Prognosetopologie anbinden
Tests / test (push) Successful in 49s
2026-09-23 05:46:07 +00:00
dh 1e6481330a feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s
2026-09-23 04:36:59 +00:00
dh dbd728dd03 feat: Anlagentopologie im Manager abbilden 2026-09-22 17:11:12 +00:00
dh a67018e68f fix: Waermepumpe ohne Vorgabe normal betreiben
Tests / test (push) Successful in 45s
2026-09-22 15:13:04 +00:00
dh 0f1e0c9fbb Installations-ID bei Manager-Updates erhalten
Tests / test (push) Successful in 45s
2026-09-22 15:06:31 +00:00
dh 693beb2263 docs: Projektstatus und Einrichtungsentscheidung aktualisieren
Tests / test (push) Successful in 49s
2026-09-22 14:43:36 +00:00
dh b1cb289922 docs: Ladestation Stand-Alone vollständig dokumentieren 2026-09-22 14:43:36 +00:00
dh 98ee569178 feat: Demoanlage um Batterie und Tagesprofile erweitern 2026-09-22 14:43:36 +00:00
dh 2392a9c209 feat: Lizenzkontingente im Manager durchsetzen 2026-09-22 14:43:36 +00:00
82 changed files with 12179 additions and 708 deletions
+6
View File
@@ -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
View File
@@ -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.
+14
View File
@@ -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
View File
@@ -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,
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+35
View File
@@ -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
}
]
},
+413 -47
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -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;
}
}
+57 -9
View File
@@ -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)
+6 -5
View File
@@ -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);
}
+8 -9
View File
@@ -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);
}
}
+14
View File
@@ -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",
+35 -14
View File
@@ -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');
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -6
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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 |
+5 -3
View File
@@ -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
+16 -7
View File
@@ -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
+19 -8
View File
@@ -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
+311 -43
View File
@@ -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
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -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
+60 -17
View File
@@ -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.
+414 -79
View File
@@ -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 ? '&rarr;' : ($grid > 25 ? '&larr;' : '&harr;');
$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">&rarr;</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">&rarr;</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
View File
@@ -7,6 +7,6 @@
"version": "8.0"
},
"version": "0.1",
"build": 0,
"date": 0
"build": 1,
"date": 1790662541
}
+406
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+29 -4
View File
@@ -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),
];
+122
View File
@@ -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.');
}
}
+78
View File
@@ -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;
}
}
+40 -5
View File
@@ -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
View File
@@ -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)
);
}
}
+108
View File
@@ -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
View File
@@ -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);
}
}
+211
View File
@@ -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)));
}
}
}
+27
View File
@@ -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
*/
+131
View File
@@ -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;
}
}
+19 -1
View File
@@ -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);
+156
View File
@@ -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'
);
}
}
+24
View File
@@ -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');
+63 -12
View File
@@ -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
);
}
}
+75
View File
@@ -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);
+31
View File
@@ -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(
+45
View File
@@ -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));
+23
View File
@@ -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> */
+85
View File
@@ -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');
}
}
+151
View File
@@ -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);
}
}
}
}
}
+46 -7
View File
@@ -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);
}
}
+107 -6
View File
@@ -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
);
}
}
+3
View File
@@ -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']);
}
}
+165
View File
@@ -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(
+114
View File
@@ -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);
}
}
+269 -1
View File
@@ -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
View File
@@ -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);
+425
View File
@@ -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);
}
}
+11
View File
@@ -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();
+131
View File
@@ -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',
]]
);
}
}
+173
View File
@@ -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;
}
+1
View File
@@ -47,6 +47,7 @@ return [
'file' => __DIR__ . '/modules/Manager.php',
'paths' => [
'Manager/*',
'libs/EinspeiseRegler.php',
'libs/Lizenzpruefung.php',
'libs/ManagerRegler.php',
'libs/ManagerSchnittstelle.php',
+36 -4
View File
@@ -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
+47
View File
@@ -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 {
+167 -1
View File
@@ -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);
});
};
+384 -12
View File
@@ -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);
});
};
+427 -4
View File
@@ -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 &amp; 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
);
});
};
+1
View File
@@ -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)));
});
};
+23 -5
View File
@@ -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
+109
View File
@@ -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 = [];
+34 -2
View File
@@ -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');