2 Commits
Author SHA1 Message Date
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
7 changed files with 161 additions and 10 deletions
+1
View File
@@ -21,6 +21,7 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert.
- 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.
+67 -2
View File
@@ -180,6 +180,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterVariableFloat('Netzleistung', 'Netzleistung', '', 30);
$this->RegisterAttributeString('VerbraucherCache', '{}');
$this->RegisterAttributeString('VerbraucherEnergie', '{}');
$this->RegisterAttributeString('LetzteSollwerte', '{}');
$this->RegisterAttributeString('LetztePvGrenzen', '{}');
$this->RegisterAttributeString('Netzfahrplan', '{}');
@@ -603,15 +604,78 @@ class Manager extends IPSModule implements ManagerSchnittstelle
throw new InvalidArgumentException('Der Absender ist diesem Manager nicht aktiv zugeordnet.');
}
$jetzt = time();
$this->aktualisiereVerbraucherEnergie($absenderID, $daten, $jetzt);
$cache = $this->leseJsonAttribut('VerbraucherCache');
$cache[(string) $absenderID] = [
'EmpfangenAm' => time(),
'EmpfangenAm' => $jetzt,
'Daten' => $daten,
];
$this->WriteAttributeString('VerbraucherCache', json_encode($cache, JSON_THROW_ON_ERROR));
$this->regeln(false);
}
/** @param array<string, mixed> $daten */
private function aktualisiereVerbraucherEnergie(int $instanzID, array $daten, int $jetzt): void
{
$energien = $this->leseJsonAttribut('VerbraucherEnergie');
$bisher = $energien[(string) $instanzID] ?? [];
$energieWh = is_array($bisher) && is_numeric($bisher['Energie_Wh'] ?? null)
? (float) $bisher['Energie_Wh']
: 0.0;
$letzterZeitpunkt = is_array($bisher) && is_int($bisher['Zeitpunkt'] ?? null)
? $bisher['Zeitpunkt']
: 0;
$letzteLeistung = is_array($bisher) && is_numeric($bisher['Leistung_W'] ?? null)
? (float) $bisher['Leistung_W']
: 0.0;
if ($letzterZeitpunkt > 0 && $jetzt > $letzterZeitpunkt) {
$dauer = min(
$jetzt - $letzterZeitpunkt,
$this->ReadPropertyInteger('VerbraucherTimeout')
);
$energieWh += $letzteLeistung * $dauer / 3600.0;
}
$energien[(string) $instanzID] = [
'Energie_Wh' => $energieWh,
'Zeitpunkt' => $jetzt,
'Leistung_W' => $this->verbraucherleistung($daten),
];
$this->WriteAttributeString(
'VerbraucherEnergie',
json_encode($energien, JSON_THROW_ON_ERROR)
);
}
/** @param array<string, mixed> $daten */
private function verbraucherleistung(array $daten): float
{
if ($daten['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN
&& is_numeric($daten['Istleistung_W'])
) {
return (float) $daten['Istleistung_W'];
}
return (float) (ManagerRegler::angenommenerSollwert($daten) ?? 0);
}
/** @return array<int, float> */
private function bezogeneVerbraucherEnergienWh(): array
{
$ergebnis = [];
foreach ($this->leseJsonAttribut('VerbraucherEnergie') as $instanzID => $eintrag) {
if (!is_array($eintrag) || !is_numeric($eintrag['Energie_Wh'] ?? null)) {
continue;
}
$ergebnis[(int) $instanzID] = (float) $eintrag['Energie_Wh'];
}
return $ergebnis;
}
private function regeln(bool $erneuern): void
{
if (!$this->aktualisiereLizenzfreigabe($this->ReadPropertyString('Lizenzcode'), false)) {
@@ -699,7 +763,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$netzleistung,
$zielnetzleistung,
$verbraucher,
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV'
$betriebsart === ManagerRegler::BETRIEBSART_PEAK ? 'PrioritaetPeak' : 'PrioritaetPV',
$this->bezogeneVerbraucherEnergienWh()
);
$this->setzeDiagnosewert('Verteilbudget', (float) $ergebnis['Verteilbudget_W']);
$this->setzeDiagnosewert('Verbraucherstatus', json_encode([
+5 -2
View File
@@ -310,8 +310,11 @@ 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
und danach stabil nach Instanz-ID sortiert. Zuerst reserviert der Manager für
jeden Verbraucher den kleinsten erlaubten Leistungswert. Nur das danach
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
+9 -4
View File
@@ -53,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);
+19 -2
View File
@@ -92,6 +92,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 +105,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.');
@@ -125,15 +127,30 @@ final class ManagerRegler
continue;
}
$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),
'Leistungswerte_W' => $daten['Leistungswerte_W'],
];
}
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);
+27
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([
+33
View File
@@ -113,6 +113,39 @@ final class ManagerReglerTest extends TestCase
self::assertSame(500, $ergebnis['Restabweichung_W']);
}
public function testGleichePrioritaetBevorzugtWieEnelix1DenVerbraucherMitWenigerEnergie(): void
{
$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 testEnergievergleichVerwendetWieEnelix1ZweiKilowattstundenGruppen(): 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 => 3000, 20 => 0], $ergebnis['Sollwerte']);
}
public function testFestwertWirdUnabhaengigVonAenderungMoeglichZugewiesen(): void
{
$fest = $this->verbraucher(10, 0, [3000], 3000, false);