5 Commits
Author SHA1 Message Date
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
23 changed files with 1475 additions and 150 deletions
+11 -4
View File
@@ -82,19 +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 der durchgaengige Bereich von der
maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
- Unterhalb der Reserve wird die maximale Ladeleistung als fester Wert angeboten.
- 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
@@ -133,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
}
]
},
+47 -2
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);
@@ -185,6 +187,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);
}
@@ -251,6 +258,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(
@@ -382,7 +397,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];
@@ -776,6 +794,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'),
@@ -793,7 +819,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) {
@@ -1096,6 +1125,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,
+9
View File
@@ -2,6 +2,14 @@
Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
## Unreleased
- 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
@@ -18,6 +26,7 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
- 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.
+11 -1
View File
@@ -100,6 +100,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$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);
@@ -236,7 +237,6 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
if ((bool) $wert) {
$this->SetTimerInterval('RueckmeldungVerzoegert', 0);
}
$this->aktualisiere(false);
$this->sendeVerbraucherdaten();
return;
@@ -315,6 +315,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell
$phasenzahl = $this->ReadAttributeInteger('ZustandPhasenzahl');
$maximalstrom = min(
$this->ReadPropertyInteger('MaximalerLadestrom'),
max(6, $this->ReadAttributeInteger('GeraeteMaximalstrom')),
max(6, $this->ReadAttributeInteger('ErkannterMaximalstrom'))
);
$this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom);
@@ -423,6 +424,15 @@ 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;
+52 -9
View File
@@ -260,10 +260,14 @@
"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",
"caption": "Wechselrichter / AC-Wandler",
"rowCount": 4,
"add": true,
"delete": true,
@@ -459,6 +463,10 @@
}
]
},
{
"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",
@@ -722,26 +730,61 @@
{
"type": "CheckBox",
"name": "EnergyPieAnzeigen",
"caption": "Energy Pie unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergyPieAnzeigen', $EnergyPieAnzeigen);"
"caption": "Energy Pie unter dem Manager anzeigen"
},
{
"type": "CheckBox",
"name": "EnergiediagrammeAnzeigen",
"caption": "Leistungs- und Energiediagramm unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergiediagrammeAnzeigen', $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",
"onChange": "IPS_RequestAction($id, 'FormFunFactsAnzeigen', $FunFactsAnzeigen);"
"caption": "Energy Facts unter dem Manager anzeigen"
},
{
"type": "CheckBox",
"name": "EnergieflussAnzeigen",
"caption": "Energiefluss unter dem Manager anzeigen",
"onChange": "IPS_RequestAction($id, 'FormEnergieflussAnzeigen', $EnergieflussAnzeigen);"
"caption": "Energiefluss unter dem Manager anzeigen"
},
{
"type": "Select",
+75 -11
View File
@@ -171,6 +171,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyInteger('EnergieLoeschenMonate', 0);
$this->RegisterPropertyBoolean('EnergyPieAnzeigen', false);
$this->RegisterPropertyBoolean('EnergiediagrammeAnzeigen', false);
$this->RegisterPropertyInteger('DiagrammPVModus', 0);
$this->RegisterPropertyInteger('DiagrammBatterieModus', 0);
$this->RegisterPropertyBoolean('LadestationenSeparatAnzeigen', true);
$this->RegisterPropertyBoolean('VerbraucherSeparatAnzeigen', true);
$this->RegisterPropertyBoolean('FunFactsAnzeigen', false);
$this->RegisterPropertyBoolean('EnergieflussAnzeigen', false);
$this->RegisterPropertyInteger('EnergieflussLeistungseinheit', 0);
@@ -203,6 +207,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeInteger('MessungLetzteEnergieaufzeichnung', 0);
$this->RegisterAttributeString('MessungEnergiePuffer', '{}');
$this->RegisterAttributeString('MessungZaehlerstaende', '{}');
$this->RegisterAttributeString('EnergiediagrammKonfiguration', '{}');
$this->RegisterAttributeString('EnergieflussKonfiguration', '{}');
$this->RegisterAttributeBoolean('EnergieflussNeuErzeugen', false);
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
$this->RegisterTimer('Regelzyklus', 0, '');
@@ -212,6 +219,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
);
$this->RegisterTimer(
'RegelungVerzoegert',
0,
"IPS_RequestAction(\$_IPS['TARGET'], 'RegelnVerzoegert', false);"
);
$this->RegisterTimer(
'PrognoseSenden',
0,
@@ -238,13 +250,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
try {
$this->pruefeKonfiguration();
$this->konfiguriereEnergiearchiv();
$this->synchronisiereEnergievisualisierungen();
$this->konfiguriereEnergiearchiv();
} catch (Throwable $fehler) {
$this->SetStatus(self::STATUS_KONFIGURATION_UNGUELTIG);
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', 0);
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval('StoerungenSenden', 0);
$this->SetTimerInterval('MesswerteAufzeichnen', 0);
@@ -256,6 +269,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->SetTimerInterval('Regelzyklus', 0);
$this->SetTimerInterval('VorgabenErneuern', 0);
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->SetTimerInterval('PrognoseSenden', 0);
$this->SetTimerInterval(
'StoerungenSenden',
@@ -449,6 +463,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->regeln((bool) $wert);
return;
case 'RegelnVerzoegert':
$this->SetTimerInterval('RegelungVerzoegert', 0);
$this->regeln(false);
return;
case 'PrognoseSenden':
$this->sendePrognoseTelemetrie();
return;
@@ -616,7 +635,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->aktualisiereEnergieflusswerte();
$this->regeln(false);
$this->aktualisiereDiagrammMesswerte();
// Den synchronen Rueckruf in die noch meldende Verbraucherinstanz vermeiden.
$this->SetTimerInterval('RegelungVerzoegert', 100);
}
/** @param array<string, mixed> $daten */
@@ -1008,13 +1030,20 @@ class Manager extends IPSModule implements ManagerSchnittstelle
foreach ($ids as $instanzID) {
$eintrag = $cache[(string) $instanzID] ?? null;
$grund = '';
if (!is_array($eintrag) || !isset($eintrag['EmpfangenAm'], $eintrag['Daten'])) {
$grund = 'Noch keine Meldung';
} elseif (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
$grund = 'Meldung ist veraltet';
} elseif (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
$grund = 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
$synchronisierungsfehler = '';
if (in_array($grund, ['Noch keine Meldung', 'Meldung ist veraltet'], true)) {
try {
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '1');
IPS_RequestAction($instanzID, 'Melden', true);
} catch (Throwable $fehler) {
$synchronisierungsfehler = $fehler->getMessage();
} finally {
$this->SetBuffer('VerbraucherSynchronisierungAktiv', '0');
}
$cache = $this->leseJsonAttribut('VerbraucherCache');
$eintrag = $cache[(string) $instanzID] ?? null;
$grund = $this->verbraucherMeldegrund($eintrag, $betriebsart, $timeout);
}
if ($grund !== '') {
@@ -1025,8 +1054,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
try {
$this->sendeManagerdaten($instanzID, $betriebsart, null);
} catch (Throwable $fehler) {
$stoerungen[] = $detail['Name'] . ': Betriebsart konnte nicht uebermittelt werden: '
. $fehler->getMessage();
$synchronisierungsfehler = $fehler->getMessage();
}
if ($synchronisierungsfehler !== '') {
$stoerungen[] = $detail['Name'] . ': Vollmeldung konnte nicht angefordert werden: '
. $synchronisierungsfehler;
}
continue;
}
@@ -1039,6 +1071,27 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return $aktuell;
}
/** @param array<string, mixed>|null $eintrag */
private function verbraucherMeldegrund(
?array $eintrag,
string $betriebsart,
int $timeout
): string {
if (!isset($eintrag['EmpfangenAm'], $eintrag['Daten'])
|| !is_array($eintrag['Daten'])
) {
return 'Noch keine Meldung';
}
if (time() - (int) $eintrag['EmpfangenAm'] > $timeout) {
return 'Meldung ist veraltet';
}
if (($eintrag['Daten']['Betriebsart'] ?? null) !== $betriebsart) {
return 'Leistungsangebot gehoert nicht zur Betriebsart ' . $betriebsart;
}
return '';
}
private function setzeNichtGeregelteVerbraucher(string $grund): void
{
try {
@@ -2120,6 +2173,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->WriteAttributeInteger('MessungLetzteEnergieaufzeichnung', $jetzt);
}
$this->aktualisiereDiagrammMesswerte(true);
$funFactsID = @IPS_GetObjectIDByIdent('EnergieFunFacts', $this->InstanceID);
if (is_int($funFactsID) && $funFactsID > 0 && IPS_VariableExists($funFactsID)) {
$this->aktualisiereFunFacts($funFactsID);
@@ -2647,6 +2701,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new InvalidArgumentException($property . ' darf nicht negativ sein.');
}
}
foreach (['DiagrammPVModus', 'DiagrammBatterieModus'] as $property) {
if (!in_array($this->ReadPropertyInteger($property), [0, 1, 2], true)) {
throw new InvalidArgumentException($property . ' muss 0, 1 oder 2 sein.');
}
}
if (!in_array($this->ReadPropertyInteger('EnergieflussLeistungseinheit'), [0, 1], true)) {
throw new InvalidArgumentException(
'EnergieflussLeistungseinheit muss 0 oder 1 sein.'
);
}
$energieLoeschen = $this->ReadPropertyInteger('EnergieLoeschenMonate');
$energieVerdichten = $this->ReadPropertyInteger('EnergieVerdichtenMonate');
if ($energieLoeschen > 0 && $energieLoeschen <= $energieVerdichten) {
+8 -3
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 |
@@ -81,9 +83,12 @@ wird.
PV und Peak verwenden weiterhin die bisherige SoC-Logik. Feste Schutz- oder
Peakvorgaben bleiben Einzelwerte; frei regelbare Angebote werden als Bereiche
gemeldet. Die Hysterese wird als persistenter Modulzustand gefuehrt. Bei
Wechselrichtersteuerung, lokaler Deaktivierung oder ungueltigen Messwerten
lautet das Angebot [0].
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
+30 -9
View File
@@ -310,15 +310,36 @@ 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. Bei gleicher Priorität bevorzugt der Manager wie Enelix 1 den
Verbraucher mit der bisher kleineren bezogenen Energie; verglichen wird in
2-kWh-Gruppen. Erst wenn auch diese Gruppe gleich ist, entscheidet stabil die
Instanz-ID. Zuerst reserviert der Manager für jeden Verbraucher den kleinsten
erlaubten Leistungswert. Nur das danach
verbleibende Budget wird in Prioritätsreihenfolge 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.
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
+10 -5
View File
@@ -52,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) {
@@ -61,12 +62,16 @@ 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];
}
$maxLaden = (int) floor($maxLadenW);
$maxNachladen = (int) floor(min($maxLadenW, $maxNachladenW ?? $maxLadenW));
$maxEntladen = (int) floor($maxEntladenW);
if ($peakbetrieb) {
if ($ladezustand > $reserve && !$hystereseAktiv) {
@@ -80,10 +85,10 @@ final class BatterieRegler
return self::leistungsbereich(-$maxEntladen, 0);
}
if ($ladezustand > $minimum) {
return self::leistungsbereich(-$maxEntladen, $maxLaden);
return self::leistungsbereich(-$maxEntladen, $maxNachladen);
}
return self::leistungsbereich(0, $maxLaden);
return self::leistungsbereich(0, $maxNachladen);
}
if ($ladezustand > 99.0) {
@@ -93,10 +98,10 @@ final class BatterieRegler
return self::leistungsbereich(-$maxEntladen, $maxLaden);
}
if ($ladezustand >= $reserve && $hystereseAktiv) {
return self::leistungsbereich(0, $maxLaden);
return self::leistungsbereich(0, $maxNachladen);
}
return [$maxLaden];
return [$maxNachladen];
}
/**
+15 -1
View File
@@ -91,7 +91,7 @@ final class LadestationAdapter
}
/**
* @return array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float, Phasenzahl?: int}
* @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
{
@@ -115,6 +115,20 @@ final class LadestationAdapter
'Rohstatus' => (int) $daten['car'],
'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])
+611 -62
View File
@@ -55,6 +55,38 @@ trait ManagerEnergieTrait
}
}
foreach (IPS_GetChildrenIDs($this->InstanceID) as $variableID) {
$objekt = IPS_GetObject($variableID);
$ident = (string) ($objekt['ObjectIdent'] ?? '');
if ((int) ($objekt['ObjectType'] ?? -1) !== 2
|| !str_starts_with($ident, 'Diagramm')
) {
continue;
}
$istEnergie = str_contains($ident, 'Energie')
|| str_contains($ident, 'BatterieLaden_')
|| str_contains($ident, 'BatterieEntladen_');
AC_SetLoggingStatus($archivID, $variableID, $aktiv);
AC_SetAggregationType($archivID, $variableID, $istEnergie ? 1 : 0);
AC_SetGraphStatus($archivID, $variableID, $aktiv);
$regeln = [-1 => $istEnergie ? $energietyp : $leistungstyp];
$verdichten = $istEnergie
? $this->ReadPropertyInteger('EnergieVerdichtenMonate')
: 0;
if ($verdichten > 0) {
$regeln[$verdichten] = 3;
}
$loeschen = $istEnergie
? $this->ReadPropertyInteger('EnergieLoeschenMonate')
: $this->ReadPropertyInteger('LeistungLoeschenMonate');
if ($loeschen > 0) {
$regeln[$loeschen] = 7;
}
if ($aktiv && $this->setzeVerdichtung($archivID, $variableID, $regeln)) {
$geaendert[] = $variableID;
}
}
IPS_ApplyChanges($archivID);
foreach (array_unique($geaendert) as $variableID) {
AC_ReAggregateVariable($archivID, $variableID);
@@ -129,6 +161,7 @@ trait ManagerEnergieTrait
}
$this->entferneDiagrammUnterobjekt('Leistungsdiagramm');
$this->entferneDiagrammUnterobjekt('Energiediagramm');
$this->WriteAttributeString('EnergiediagrammKonfiguration', '{}');
}
private function synchronisiereFunFacts(bool $anzeigen): void
@@ -154,17 +187,18 @@ trait ManagerEnergieTrait
return;
}
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
return;
if ($instanzID !== 0) {
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, true);
}
$objekt = IPS_GetObject($instanzID);
$modulID = $objekt['ObjectType'] === 1
? (string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
: '';
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, true);
$this->WriteAttributeString('EnergieflussKonfiguration', '{}');
$this->WriteAttributeBoolean('EnergieflussNeuErzeugen', true);
}
private function entferneInstanzUnterobjekt(string $ident, string $modulID): void
@@ -216,6 +250,9 @@ trait ManagerEnergieTrait
if ($modulID !== self::ENERGY_PIE_MODULE_ID) {
throw new RuntimeException('Unterobjekt EnergyPie hat einen unpassenden Typ.');
}
IPS_SetHidden($instanzID, false);
return 'Bestehende Energy-Pie-Konfiguration bleibt unveraendert.';
}
IPS_SetName($instanzID, 'Energy Pie');
IPS_SetPosition($instanzID, 500);
@@ -239,6 +276,7 @@ trait ManagerEnergieTrait
if (!in_array(self::ENERGY_FLOW_MODULE_ID, IPS_GetModuleList(), true)) {
throw new RuntimeException('Das Symcon-Modul Energy Distribution ist nicht installiert.');
}
$neuErzeugen = $this->ReadAttributeBoolean('EnergieflussNeuErzeugen');
$instanzID = $this->findeUnterobjekt('Energiefluss');
if ($instanzID === 0) {
$instanzID = IPS_CreateInstance(self::ENERGY_FLOW_MODULE_ID);
@@ -252,7 +290,15 @@ trait ManagerEnergieTrait
if ($modulID !== self::ENERGY_FLOW_MODULE_ID) {
throw new RuntimeException('Unterobjekt Energiefluss hat einen unpassenden Typ.');
}
if (!$neuErzeugen) {
$this->visualisierungsKonfiguration('EnergieflussKonfiguration');
IPS_SetHidden($instanzID, false);
$this->aktualisiereEnergieflusswerte();
return 'Bestehende Energiefluss-Konfiguration bleibt unveraendert.';
}
}
$this->visualisierungsKonfiguration('EnergieflussKonfiguration');
IPS_SetName($instanzID, 'Energiefluss');
IPS_SetPosition($instanzID, 540);
IPS_SetHidden($instanzID, false);
@@ -289,7 +335,6 @@ trait ManagerEnergieTrait
$this->energieflussKnoten($netzID, 'Stromnetz', 'grid'),
$this->energieflussKnoten($hausID, 'Hausverbrauch', 'consumer'),
];
$hatBatterieverbraucher = false;
$position = 640;
foreach ($verbraucher as $eintrag) {
$istID = $this->energieflussLeistungsvariable(
@@ -311,17 +356,13 @@ trait ManagerEnergieTrait
true,
$sollID
);
$hatBatterieverbraucher = $hatBatterieverbraucher
|| $eintrag['Typ'] === 'storage';
$position += 10;
}
if (!$hatBatterieverbraucher) {
$knoten[] = $this->energieflussKnoten(
$batterieID,
'Batteriespeicher',
'storage'
);
}
$knoten[] = $this->energieflussKnoten(
$batterieID,
'Batteriespeicher',
'storage'
);
IPS_SetProperty($instanzID, 'Calculation', true);
IPS_SetProperty(
@@ -332,6 +373,7 @@ trait ManagerEnergieTrait
IPS_SetProperty($instanzID, 'EnableLimits', false);
IPS_SetProperty($instanzID, 'Restrictions', '[]');
IPS_ApplyChanges($instanzID);
$this->WriteAttributeBoolean('EnergieflussNeuErzeugen', false);
$this->aktualisiereEnergieflusswerte($verbraucher);
return 'Der Energiefluss wurde erstellt oder aktualisiert.';
@@ -360,7 +402,10 @@ trait ManagerEnergieTrait
private function energieflussLeistungsprofil(): string
{
if ($this->ReadPropertyInteger('EnergieflussLeistungseinheit') !== 1) {
$konfiguration = $this->visualisierungsKonfiguration(
'EnergieflussKonfiguration'
);
if ($konfiguration['EnergieflussLeistungseinheit'] !== 1) {
return '~Power';
}
@@ -388,18 +433,55 @@ trait ManagerEnergieTrait
return $variableID;
}
/** @return array<string, bool|int> */
private function visualisierungsKonfiguration(string $attribut): array
{
$konfiguration = $this->leseJsonAttribut($attribut);
$gueltig = isset(
$konfiguration['DiagrammPVModus'],
$konfiguration['DiagrammBatterieModus'],
$konfiguration['LadestationenSeparatAnzeigen'],
$konfiguration['VerbraucherSeparatAnzeigen'],
$konfiguration['EnergieflussLeistungseinheit']
);
if ($gueltig) {
return $konfiguration;
}
$konfiguration = [
'DiagrammPVModus' => $this->ReadPropertyInteger('DiagrammPVModus'),
'DiagrammBatterieModus' => $this->ReadPropertyInteger('DiagrammBatterieModus'),
'LadestationenSeparatAnzeigen' =>
$this->ReadPropertyBoolean('LadestationenSeparatAnzeigen'),
'VerbraucherSeparatAnzeigen' =>
$this->ReadPropertyBoolean('VerbraucherSeparatAnzeigen'),
'EnergieflussLeistungseinheit' =>
$this->ReadPropertyInteger('EnergieflussLeistungseinheit'),
];
$this->WriteAttributeString(
$attribut,
json_encode($konfiguration, JSON_THROW_ON_ERROR)
);
return $konfiguration;
}
/**
* @return list<array{
* InstanzID: int,
* Name: string,
* Typ: string,
* Istleistung_W: float,
* Sollleistung_W: float
* Sollleistung_W: float,
* Energie_Wh: float
* }>
*/
private function energieflussVerbraucher(): array
{
private function energieflussVerbraucher(
string $konfigurationsAttribut = 'EnergieflussKonfiguration'
): array {
$konfiguration = $this->visualisierungsKonfiguration($konfigurationsAttribut);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
$ergebnis = [];
foreach ($this->konfigurierteAktiveVerbraucherIDs() as $instanzID) {
if (!IPS_InstanceExists($instanzID)) {
@@ -416,17 +498,30 @@ trait ManagerEnergieTrait
(string) IPS_GetInstance($instanzID)['ModuleInfo']['ModuleID']
);
$katalog = self::VERBRAUCHER_LIZENZEN[$modulID]['Katalog'] ?? '';
if ($katalog === 'battery') {
continue;
}
if ($katalog === 'ev_charger'
&& !$konfiguration['LadestationenSeparatAnzeigen']
) {
continue;
}
if ($katalog !== 'ev_charger'
&& !$konfiguration['VerbraucherSeparatAnzeigen']
) {
continue;
}
$verbraucherEnergie = $energien[(string) $instanzID]['Energie_Wh'] ?? 0.0;
$ergebnis[] = [
'InstanzID' => $instanzID,
'Name' => IPS_GetName($instanzID),
'Typ' => match ($katalog) {
'battery' => 'storage',
'ev_charger' => 'wallbox',
default => 'consumer',
},
'Typ' => $katalog === 'ev_charger' ? 'wallbox' : 'consumer',
'Istleistung_W' => $istleistung,
'Sollleistung_W' => $sollleistung,
'Energie_Wh' => is_numeric($verbraucherEnergie)
? (float) $verbraucherEnergie
: 0.0,
];
}
@@ -443,7 +538,6 @@ trait ManagerEnergieTrait
}
$verbraucher = $verbraucher ?? $this->energieflussVerbraucher();
$verbraucherleistung = 0.0;
$hatBatterieverbraucher = false;
foreach ($verbraucher as $eintrag) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussIst_' . $eintrag['InstanzID'],
@@ -453,11 +547,7 @@ trait ManagerEnergieTrait
'EnergieflussSoll_' . $eintrag['InstanzID'],
(float) $eintrag['Sollleistung_W']
);
if ($eintrag['Typ'] === 'storage') {
$hatBatterieverbraucher = true;
} else {
$verbraucherleistung += max(0.0, (float) $eintrag['Istleistung_W']);
}
$verbraucherleistung += max(0.0, (float) $eintrag['Istleistung_W']);
}
try {
@@ -472,12 +562,10 @@ trait ManagerEnergieTrait
'EnergieflussHaus',
max(0.0, $leistungen['Haus'] - $verbraucherleistung)
);
if (!$hatBatterieverbraucher) {
$this->setzeEnergieflussLeistungswert(
'EnergieflussBatterie',
$leistungen['Batterie']
);
}
$this->setzeEnergieflussLeistungswert(
'EnergieflussBatterie',
$leistungen['Batterie']
);
}
private function setzeEnergieflussLeistungswert(string $ident, float $watt): void
@@ -486,33 +574,487 @@ trait ManagerEnergieTrait
if (!is_int($variableID) || $variableID <= 0 || !IPS_VariableExists($variableID)) {
return;
}
$faktor = $this->ReadPropertyInteger('EnergieflussLeistungseinheit') === 1
? 0.001
: 1.0;
$konfiguration = $this->visualisierungsKonfiguration(
'EnergieflussKonfiguration'
);
$faktor = $konfiguration['EnergieflussLeistungseinheit'] === 1 ? 0.001 : 1.0;
SetValue($variableID, $watt * $faktor);
}
private function erstelleEnergiediagramme(): string
{
$leistung = [
$this->chartDatensatz('PVLeistungArchiv', 'PV', '#F4C542', '~Power'),
$this->chartDatensatz('HausverbrauchLeistungArchiv', 'Haus', '#22A06B', '~Power'),
$this->chartDatensatz('NetzleistungArchiv', 'Netz', '#E95D5D', '~Power'),
$this->chartDatensatz('BatterieleistungArchiv', 'Batterie', '#8B5CF6', '~Power'),
];
$energie = [
$this->chartDatensatz('PVEnergie', 'PV', '#F4C542', '~Electricity'),
$this->chartDatensatz('Hausenergie', 'Haus', '#22A06B', '~Electricity'),
$this->chartDatensatz('NetzbezugEnergie', 'Netzbezug', '#E95D5D', '~Electricity'),
$this->chartDatensatz('EinspeisungEnergie', 'Einspeisung', '#2F80ED', '~Electricity'),
$this->chartDatensatz('BatterieLadenEnergie', 'Batterie laden', '#8B5CF6', '~Electricity'),
$this->chartDatensatz('BatterieEntladenEnergie', 'Batterie entladen', '#F59E0B', '~Electricity'),
];
$konfiguration = $this->visualisierungsKonfiguration(
'EnergiediagrammKonfiguration'
);
$pvAnlagen = $this->diagrammPVAnlagen();
$batterien = $this->diagrammBatterien();
$verbraucher = $this->energieflussVerbraucher(
'EnergiediagrammKonfiguration'
);
$this->registriereDiagrammVariablen($pvAnlagen, $batterien, $verbraucher);
$this->aktualisiereDiagrammMesswerte(true);
$leistung = [];
$energie = [];
if ($konfiguration['DiagrammPVModus'] !== 1) {
$leistung[] = $this->chartDatensatz(
'PVLeistungArchiv',
'PV gesamt',
'#F4C542',
'~Power'
);
$energie[] = $this->chartDatensatz(
'PVEnergie',
'PV gesamt',
'#F4C542',
'~Electricity',
'bar'
);
}
if ($konfiguration['DiagrammPVModus'] !== 0) {
foreach ($pvAnlagen as $index => $anlage) {
$farbe = $this->diagrammFarbe($index);
if ($anlage['LeistungVariableID'] > 0) {
$leistung[] = $this->chartDatensatz(
$anlage['LeistungIdent'],
$anlage['Name'],
$farbe,
'~Power'
);
}
if ($anlage['EnergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$anlage['EnergieIdent'],
$anlage['Name'],
$farbe,
'~Electricity',
'bar'
);
}
}
}
$hausTitel = $verbraucher === [] ? 'Haus' : 'Haus ohne separat dargestellte Verbraucher';
$leistung[] = $this->chartDatensatz(
'DiagrammHausLeistung',
$hausTitel,
'#22A06B',
'~Power'
);
$leistung[] = $this->chartDatensatz(
'NetzleistungArchiv',
'Netz',
'#E95D5D',
'~Power'
);
$energie[] = $this->chartDatensatz(
'DiagrammHausEnergie',
$hausTitel,
'#22A06B',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'NetzbezugEnergie',
'Netzbezug',
'#E95D5D',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'EinspeisungEnergie',
'Netzeinspeisung',
'#2F80ED',
'~Electricity',
'bar'
);
if ($konfiguration['DiagrammBatterieModus'] !== 1) {
$leistung[] = $this->chartDatensatz(
'BatterieleistungArchiv',
'Batterie gesamt',
'#8B5CF6',
'~Power'
);
$energie[] = $this->chartDatensatz(
'BatterieLadenEnergie',
'Batterie laden gesamt',
'#8B5CF6',
'~Electricity',
'bar'
);
$energie[] = $this->chartDatensatz(
'BatterieEntladenEnergie',
'Batterie entladen gesamt',
'#F59E0B',
'~Electricity',
'bar'
);
}
if ($konfiguration['DiagrammBatterieModus'] !== 0) {
foreach ($batterien as $index => $batterie) {
$farbe = $this->diagrammFarbe($index + count($pvAnlagen));
if ($batterie['LeistungVariableID'] > 0) {
$leistung[] = $this->chartDatensatz(
$batterie['LeistungIdent'],
$batterie['Name'],
$farbe,
'~Power'
);
}
if ($batterie['LadeenergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$batterie['LadeenergieIdent'],
$batterie['Name'] . ' laden',
$farbe,
'~Electricity',
'bar'
);
}
if ($batterie['EntladeenergieVariableID'] > 0) {
$energie[] = $this->chartDatensatz(
$batterie['EntladeenergieIdent'],
$batterie['Name'] . ' entladen',
$farbe,
'~Electricity',
'bar'
);
}
}
}
foreach ($verbraucher as $index => $eintrag) {
$farbe = $this->diagrammFarbe(
$index + count($pvAnlagen) + count($batterien)
);
$leistung[] = $this->chartDatensatz(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$eintrag['Name'],
$farbe,
'~Power'
);
$energie[] = $this->chartDatensatz(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
$eintrag['Name'],
$farbe,
'~Electricity',
'bar'
);
}
$this->erstelleDiagramm('Leistungsdiagramm', 'Leistungen', $leistung, 510);
$this->erstelleDiagramm('Energiediagramm', 'Energien', $energie, 520);
return 'Die Diagramme Leistungen und Energien wurden erstellt oder aktualisiert.';
}
/** @return list<array<string, mixed>> */
private function diagrammPVAnlagen(): array
{
$verwendet = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenPVFlaechen') as $flaeche) {
$wechselrichterID = trim((string) ($flaeche['WechselrichterID'] ?? ''));
if ($wechselrichterID !== '') {
$verwendet[$wechselrichterID] = true;
}
}
$ergebnis = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenWechselrichter') as $eintrag) {
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($id === '' || ($verwendet !== [] && !isset($verwendet[$id]))) {
continue;
}
if ($verwendet === []
&& !in_array((string) ($eintrag['Typ'] ?? ''), ['pv', 'hybrid'], true)
) {
continue;
}
$suffix = substr(hash('sha256', 'pv:' . $id), 0, 12);
$leistungID = (int) ($eintrag['IstleistungVariableID'] ?? 0);
$energieID = (int) ($eintrag['ErzeugungsenergieVariableID'] ?? 0);
$ergebnis[] = [
'Name' => trim((string) ($eintrag['Name'] ?? '')) ?: $id,
'LeistungVariableID' => $this->istNumerischeVariable($leistungID)
? $leistungID
: 0,
'Leistungsfaktor' => (float) ($eintrag['Istleistungsfaktor'] ?? 1.0),
'EnergieVariableID' => $this->istNumerischeVariable($energieID)
? $energieID
: 0,
'Energiefaktor' => (float) ($eintrag['Erzeugungsenergiefaktor'] ?? 1.0),
'LeistungIdent' => 'DiagrammPVLeistung_' . $suffix,
'EnergieIdent' => 'DiagrammPVEnergie_' . $suffix,
];
}
return $ergebnis;
}
/** @return list<array<string, mixed>> */
private function diagrammBatterien(): array
{
$ergebnis = [];
foreach ($this->leseAnlagenKonfigurationsliste('AnlagenBatterien') as $eintrag) {
$id = trim((string) ($eintrag['ID'] ?? ''));
if ($id === '') {
continue;
}
$suffix = substr(hash('sha256', 'batterie:' . $id), 0, 12);
$leistungID = (int) ($eintrag['LeistungVariableID'] ?? 0);
$ladenID = (int) ($eintrag['LadeenergieVariableID'] ?? 0);
$entladenID = (int) ($eintrag['EntladeenergieVariableID'] ?? 0);
$ergebnis[] = [
'Name' => trim((string) ($eintrag['Name'] ?? '')) ?: $id,
'LeistungVariableID' => $this->istNumerischeVariable($leistungID)
? $leistungID
: 0,
'Leistungsfaktor' => (float) ($eintrag['Leistungsfaktor'] ?? 1.0),
'LadeenergieVariableID' => $this->istNumerischeVariable($ladenID)
? $ladenID
: 0,
'EntladeenergieVariableID' => $this->istNumerischeVariable($entladenID)
? $entladenID
: 0,
'Energiefaktor' => (float) ($eintrag['Energiefaktor'] ?? 1.0),
'LeistungIdent' => 'DiagrammBatterieLeistung_' . $suffix,
'LadeenergieIdent' => 'DiagrammBatterieLaden_' . $suffix,
'EntladeenergieIdent' => 'DiagrammBatterieEntladen_' . $suffix,
];
}
return $ergebnis;
}
/**
* @param list<array<string, mixed>> $pvAnlagen
* @param list<array<string, mixed>> $batterien
* @param list<array<string, mixed>> $verbraucher
*/
private function registriereDiagrammVariablen(
array $pvAnlagen,
array $batterien,
array $verbraucher
): void {
$position = 800;
$this->diagrammMessvariable(
'DiagrammHausLeistung',
'Diagramm Hausleistung',
'~Power',
$position
);
$this->diagrammMessvariable(
'DiagrammHausEnergie',
'Diagramm Hausenergie',
'~Electricity',
$position + 1
);
$position += 10;
foreach ($pvAnlagen as $anlage) {
if ($anlage['LeistungVariableID'] > 0) {
$this->diagrammMessvariable(
$anlage['LeistungIdent'],
$anlage['Name'] . ' Leistung',
'~Power',
$position
);
}
if ($anlage['EnergieVariableID'] > 0) {
$this->diagrammMessvariable(
$anlage['EnergieIdent'],
$anlage['Name'] . ' Energie',
'~Electricity',
$position + 1
);
}
$position += 10;
}
foreach ($batterien as $batterie) {
if ($batterie['LeistungVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['LeistungIdent'],
$batterie['Name'] . ' Leistung',
'~Power',
$position
);
}
if ($batterie['LadeenergieVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['LadeenergieIdent'],
$batterie['Name'] . ' Ladeenergie',
'~Electricity',
$position + 1
);
}
if ($batterie['EntladeenergieVariableID'] > 0) {
$this->diagrammMessvariable(
$batterie['EntladeenergieIdent'],
$batterie['Name'] . ' Entladeenergie',
'~Electricity',
$position + 2
);
}
$position += 10;
}
foreach ($verbraucher as $eintrag) {
$this->diagrammMessvariable(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Leistung',
'~Power',
$position
);
$this->diagrammMessvariable(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
$eintrag['Name'] . ' Energie',
'~Electricity',
$position + 1
);
$position += 10;
}
}
private function diagrammMessvariable(
string $ident,
string $name,
string $profil,
int $position
): int {
$this->RegisterVariableFloat($ident, $name, $profil, $position);
$variableID = $this->GetIDForIdent($ident);
IPS_SetHidden($variableID, true);
return $variableID;
}
private function aktualisiereDiagrammMesswerte(bool $energieAktualisieren = false): void
{
if ($this->findeUnterobjekt('Leistungsdiagramm') === 0
&& $this->findeUnterobjekt('Energiediagramm') === 0
&& $this->findeUnterobjekt('DiagrammHausLeistung') === 0
) {
return;
}
try {
$leistungen = $this->leseMessleistungen();
$pvAnlagen = $this->diagrammPVAnlagen();
$batterien = $this->diagrammBatterien();
$verbraucher = $this->energieflussVerbraucher(
'EnergiediagrammKonfiguration'
);
} catch (Throwable $fehler) {
$this->protokolliere('Energiediagramme', $fehler->getMessage());
return;
}
$separateLeistung = 0.0;
$separateEnergieKWh = 0.0;
foreach ($verbraucher as $eintrag) {
$leistung = max(0.0, (float) $eintrag['Istleistung_W']);
$separateLeistung += $leistung;
$separateEnergieKWh += max(0.0, (float) $eintrag['Energie_Wh']) / 1000.0;
$this->setzeDiagrammMesswert(
'DiagrammVerbraucherLeistung_' . $eintrag['InstanzID'],
$leistung
);
if ($energieAktualisieren) {
$this->setzeDiagrammMesswert(
'DiagrammVerbraucherEnergie_' . $eintrag['InstanzID'],
max(0.0, (float) $eintrag['Energie_Wh']) / 1000.0
);
}
}
$this->setzeDiagrammMesswert(
'DiagrammHausLeistung',
max(0.0, $leistungen['Haus'] - $separateLeistung)
);
if ($energieAktualisieren) {
$this->setzeDiagrammMesswert(
'DiagrammHausEnergie',
max(
0.0,
(float) $this->GetValue('Hausenergie') - $separateEnergieKWh
)
);
}
foreach ($pvAnlagen as $anlage) {
if ($anlage['LeistungVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$anlage['LeistungIdent'],
max(
0.0,
(float) GetValue($anlage['LeistungVariableID'])
* $anlage['Leistungsfaktor']
)
);
}
if ($energieAktualisieren && $anlage['EnergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$anlage['EnergieIdent'],
max(
0.0,
(float) GetValue($anlage['EnergieVariableID'])
* $anlage['Energiefaktor']
)
);
}
}
foreach ($batterien as $batterie) {
if ($batterie['LeistungVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['LeistungIdent'],
(float) GetValue($batterie['LeistungVariableID'])
* $batterie['Leistungsfaktor']
);
}
if ($energieAktualisieren && $batterie['LadeenergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['LadeenergieIdent'],
max(
0.0,
(float) GetValue($batterie['LadeenergieVariableID'])
* $batterie['Energiefaktor']
)
);
}
if ($energieAktualisieren && $batterie['EntladeenergieVariableID'] > 0) {
$this->setzeDiagrammMesswert(
$batterie['EntladeenergieIdent'],
max(
0.0,
(float) GetValue($batterie['EntladeenergieVariableID'])
* $batterie['Energiefaktor']
)
);
}
}
}
private function setzeDiagrammMesswert(string $ident, float $wert): void
{
$variableID = @IPS_GetObjectIDByIdent($ident, $this->InstanceID);
if (is_int($variableID) && $variableID > 0 && IPS_VariableExists($variableID)) {
SetValue($variableID, $wert);
}
}
private function diagrammFarbe(int $index): string
{
$farben = [
'#A85D3C',
'#007C91',
'#6B5B95',
'#2E7D32',
'#C43E1C',
'#3D5A80',
'#8D6E63',
'#00897B',
];
return $farben[$index % count($farben)];
}
/** @param list<array<string, mixed>> $datensaetze */
private function erstelleDiagramm(
string $ident,
@@ -521,7 +1063,8 @@ trait ManagerEnergieTrait
int $position
): int {
$medienID = $this->findeUnterobjekt($ident);
if ($medienID === 0) {
$neu = $medienID === 0;
if ($neu) {
$medienID = IPS_CreateMedia(4);
IPS_SetParent($medienID, $this->InstanceID);
IPS_SetIdent($medienID, $ident);
@@ -530,6 +1073,11 @@ trait ManagerEnergieTrait
if ($objekt['ObjectType'] !== 5 || (int) IPS_GetMedia($medienID)['MediaType'] !== 4) {
throw new RuntimeException('Unterobjekt ' . $ident . ' ist kein Diagramm.');
}
if (!$neu) {
IPS_SetHidden($medienID, false);
return $medienID;
}
IPS_SetName($medienID, $name);
IPS_SetPosition($medienID, $position);
IPS_SetHidden($medienID, false);
@@ -546,16 +1094,17 @@ trait ManagerEnergieTrait
string $ident,
string $titel,
string $farbe,
string $profil
string $profil,
string $typ = 'line'
): array {
return [
'variableID' => $this->GetIDForIdent($ident),
'fillColor' => 'clear',
'fillColor' => $typ === 'bar' ? $farbe : 'clear',
'strokeColor' => $farbe,
'timeOffset' => 0,
'visible' => true,
'title' => $titel,
'type' => 'line',
'type' => $typ,
'side' => 'left',
'profile' => $profil,
];
+96 -7
View File
@@ -208,6 +208,7 @@ final class ManagerRegler
'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,
@@ -254,6 +255,7 @@ final class ManagerRegler
}
$rest = $verteilbudget - $nichtSteuerbar - $ausgangssumme;
$gruppen = [];
foreach ($steuerbar as $eintrag) {
if ($eintrag['Festwert'] || $eintrag['Festgehalten']) {
continue;
@@ -262,13 +264,8 @@ final class ManagerRegler
$instanzID = $eintrag['InstanzID'];
$ausgangswert = $sollwerte[$instanzID];
if ($positiveRichtung) {
if ($rest <= 0) {
continue;
}
$neuerSollwert = self::groessterLeistungswertBis(
$eintrag['Leistungswerte_W'],
$ausgangswert + $rest
);
$gruppen[$eintrag['Prioritaet']][] = $eintrag;
continue;
} else {
if ($rest >= 0) {
continue;
@@ -281,6 +278,9 @@ final class ManagerRegler
$rest -= $neuerSollwert - $ausgangswert;
$sollwerte[$instanzID] = $neuerSollwert;
}
foreach ($gruppen as $gruppe) {
self::verteilePositiveGruppe($gruppe, $sollwerte, $rest);
}
$zugeteilt = $nichtSteuerbar;
foreach ($steuerbar as $eintrag) {
@@ -298,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
*/
+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');
+32
View File
@@ -82,6 +82,38 @@ final class BatterieReglerTest extends TestCase
);
}
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(
+27 -2
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, Iststrom_A?: float, Phasenzahl?: int} $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,
@@ -52,6 +52,8 @@ final class LadestationAdapterTest extends TestCase
[
'Rohstatus' => 2,
'Istleistung_W' => 11000.0,
'Sollstrom_A' => 16,
'Maximalstrom_A' => 16,
'Iststrom_A' => 16.0,
'Phasenzahl' => 3,
],
@@ -64,6 +66,8 @@ final class LadestationAdapterTest extends TestCase
[
'Rohstatus' => 2,
'Istleistung_W' => 2300.0,
'Sollstrom_A' => 16,
'Maximalstrom_A' => 16,
'Iststrom_A' => 9.8,
'Phasenzahl' => 1,
],
@@ -81,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
@@ -180,6 +198,13 @@ final class LadestationAdapterTest extends TestCase
}
$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);
}
}
@@ -104,4 +104,32 @@ final class LadestationStandAloneModulstrukturTest extends TestCase
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
);
}
}
+16 -4
View File
@@ -17,6 +17,11 @@ final class ManagerEnergieModulstrukturTest extends TestCase
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)",
@@ -38,6 +43,10 @@ final class ManagerEnergieModulstrukturTest extends TestCase
'ENERGY_PIE_MODULE_ID',
'ENERGY_FLOW_MODULE_ID',
'energieflussKnoten',
"'type' => \$typ",
"'bar'",
'EnergiediagrammKonfiguration',
'EnergieflussKonfiguration',
'IPS_DeleteInstance',
'IPS_DeleteMedia',
] as $erwartet) {
@@ -45,11 +54,14 @@ final class ManagerEnergieModulstrukturTest extends TestCase
}
foreach ([
'Energieaufzeichnung',
'FormEnergyPieAnzeigen',
'FormEnergiediagrammeAnzeigen',
'FormFunFactsAnzeigen',
'FormEnergieflussAnzeigen',
'EnergieflussLeistungseinheit',
'DiagrammPVModus',
'DiagrammBatterieModus',
'LadestationenSeparatAnzeigen',
'VerbraucherSeparatAnzeigen',
'Bestehende Energy Pies, Diagramme und Energiefluesse bleiben bei Apply unveraendert',
'Wechselrichter / AC-Wandler',
'keine doppelte Batteriekonfiguration',
'MesswerteAnzeigen',
'IstleistungVariableID',
'LeistungVariableID',
+21
View File
@@ -380,4 +380,25 @@ final class ManagerModulstrukturTest extends TestCase
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']);
}
}
+170 -3
View File
@@ -166,7 +166,7 @@ final class ManagerReglerTest extends TestCase
self::assertSame(500, $ergebnis['Restabweichung_W']);
}
public function testGleichePrioritaetBevorzugtWieEnelix1DenVerbraucherMitWenigerEnergie(): void
public function testGleicheNaechsteLeistungBevorzugtWenigerEnergie(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
@@ -183,7 +183,7 @@ final class ManagerReglerTest extends TestCase
self::assertSame(0, $ergebnis['Restabweichung_W']);
}
public function testEnergievergleichVerwendetWieEnelix1ZweiKilowattstundenGruppen(): void
public function testEnergiegleichstandVergleichtExakteWhStattZweiKilowattstundenGruppen(): void
{
$ergebnis = ManagerRegler::verteile(
0.0,
@@ -196,7 +196,174 @@ final class ManagerReglerTest extends TestCase
[10 => 999.0, 20 => 0.0]
);
self::assertSame([10 => 3000, 20 => 0], $ergebnis['Sollwerte']);
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
+26
View File
@@ -163,6 +163,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 (
+20 -2
View File
@@ -209,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)));
@@ -222,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)),
+122 -25
View File
@@ -6,8 +6,13 @@ use Belevo\EnelixEMS\SymconTest\TestContext;
return static function (TestContext $test): void {
$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(
@@ -82,6 +87,8 @@ return static function (TestContext $test): void {
'ACNennleistung_kW' => 10.0,
'IstleistungVariableID' => $pvLeistungId,
'Istleistungsfaktor' => 1.0,
'ErzeugungsenergieVariableID' => $pvEnergieId,
'Erzeugungsenergiefaktor' => 1.0,
]];
$pvKonfiguration = [[
'ID' => 'pv-1',
@@ -106,6 +113,9 @@ return static function (TestContext $test): void {
'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(
@@ -113,6 +123,8 @@ return static function (TestContext $test): void {
'Manager',
[
'NetzleistungVariableID' => $netzleistungId,
'NetzbezugEnergieVariableID' => $netzbezugEnergieId,
'NetzeinspeisungEnergieVariableID' => $netzeinspeisungEnergieId,
'EnergyPieAnzeigen' => true,
'EnergiediagrammeAnzeigen' => true,
'FunFactsAnzeigen' => true,
@@ -226,11 +238,17 @@ return static function (TestContext $test): void {
});
$test->runCase('Energiewerte, Archivregeln und Unterobjekte werden erzeugt', static function (
TestContext $test
) use ($managerId): void {
) use ($managerId, $pvLeistungId): void {
IPS_RequestAction($managerId, 'MesswerteAufzeichnen', true);
$test->assertSame(
4200.0,
GetValue($test->objectByIdent('PVLeistungArchiv', $managerId))
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,
@@ -288,18 +306,69 @@ return static function (TestContext $test): void {
}
}
$pvFlussID = $test->objectByIdent('EnergieflussPV', $managerId);
$test->assertSame(4200.0, (float) GetValue($pvFlussID));
$test->assertSame(
4200.0,
(float) GetValue($pvFlussID),
'Der anfaengliche PV-Energiefluss ist falsch.'
);
$test->assertSame('~Power', IPS_GetVariable($pvFlussID)['VariableProfile']);
IPS_SetProperty($managerId, 'EnergieflussLeistungseinheit', 1);
IPS_ApplyChanges($managerId);
$test->assertSame(4.2, (float) GetValue($pvFlussID));
$test->assertSame('Enelix.Power.kW', 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));
}
IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', false);
$test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
IPS_RequestAction($managerId, 'FormEnergieflussAnzeigen', true);
$test->assertSame(false, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
$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',
@@ -322,7 +391,44 @@ return static function (TestContext $test): void {
$ident . ' wurde beim Ausschalten nicht entfernt.'
);
}
$test->assertSame(true, (bool) IPS_GetObject($energieflussID)['ObjectIsHidden']);
$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));
}
});
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
@@ -470,12 +576,11 @@ return static function (TestContext $test): void {
));
});
$test->runCase('Veralteter Verbraucher blockiert aktuelle Verbraucher nicht', static function (
$test->runCase('Veraltete Verbraucher werden aktiv neu angefordert', static function (
TestContext $test
) use (
$managerId,
$netzleistungId,
$einStufigId,
$warmwasserId,
$pufferspeicherId,
$melden,
@@ -496,17 +601,9 @@ return static function (TestContext $test): void {
IPS_RequestAction($managerId, 'Regeln', true);
$daten = $verbraucherstatus();
$test->assertSame(false, $daten['Synchronisiert'] ?? null);
$test->assertSame(2, count($daten['Verbraucher'] ?? []));
$test->assertSame(1, count($daten['NichtGeregelt'] ?? []));
$test->assertSame(
$einStufigId,
$daten['NichtGeregelt'][0]['InstanzID'] ?? null
);
$test->assertSame(
'Meldung ist veraltet',
$daten['NichtGeregelt'][0]['Grund'] ?? null
);
$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
);