diff --git a/CHANGELOG.md b/CHANGELOG.md index 13743d9..af42cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Alle wesentlichen Aenderungen an Enelix EMS werden in dieser Datei dokumentiert. - Stufenlose Leistungsbereiche fuer Batteriespeicher. - 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. diff --git a/LadestationStandAlone/form.json b/LadestationStandAlone/form.json index c2a6a05..2225c57 100644 --- a/LadestationStandAlone/form.json +++ b/LadestationStandAlone/form.json @@ -37,6 +37,13 @@ "caption": "Geraetestatus abfragen", "suffix": " s", "minimum": 1 + }, + { + "type": "NumberSpinner", + "name": "FahrzeugstromErkennungszeit", + "caption": "Fahrzeug-Maximalstrom erkennen nach", + "suffix": " s", + "minimum": 30 } ] }, diff --git a/LadestationStandAlone/module.php b/LadestationStandAlone/module.php index ca9e82a..036b148 100644 --- a/LadestationStandAlone/module.php +++ b/LadestationStandAlone/module.php @@ -34,6 +34,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell 'AenderungMoeglich', 'Stoerung', 'Stoertext', + 'FahrzeugMaximalstrom', 'LetzterGeraetebefehl', 'LeistungsangebotDiagnose', ]; @@ -50,6 +51,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->RegisterPropertyString('Benutzername', ''); $this->RegisterPropertyString('Passwort', ''); $this->RegisterPropertyInteger('MaximalerLadestrom', 16); + $this->RegisterPropertyInteger('FahrzeugstromErkennungszeit', 90); $this->RegisterPropertyInteger('Abfrageintervall', 5); $this->RegisterPropertyBoolean('Ladefreigabe', true); $this->RegisterPropertyBoolean('Solarladen', true); @@ -88,6 +90,9 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->RegisterAttributeInteger('LetzteVorgabeZeit', 0); $this->RegisterAttributeInteger('LetzterGesetzterStrom', -1); $this->RegisterAttributeBoolean('LadeleistungSeitFreigabeErkannt', false); + $this->RegisterAttributeInteger('ErkannterMaximalstrom', 32); + $this->RegisterAttributeInteger('FahrzeugstromKandidat', 0); + $this->RegisterAttributeInteger('FahrzeugstromKandidatSeit', 0); $this->RegisterTimer( 'Statuszyklus', @@ -122,6 +127,11 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->setzeZustand('FahrzeugGeladen', false); $this->WriteAttributeInteger('LetzterGesetzterStrom', -1); $this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false); + $this->WriteAttributeInteger( + 'ErkannterMaximalstrom', + $this->ReadPropertyInteger('MaximalerLadestrom') + ); + $this->setzeFahrzeugstromKandidatZurueck(); try { $this->pruefeKonfiguration(); @@ -245,7 +255,11 @@ 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('ErkannterMaximalstrom')) + ); + $this->setzeSichtbareVariable('FahrzeugMaximalstrom', $maximalstrom); $solarladen = $this->ReadAttributeBoolean('ZustandSolarladen'); $peakbetrieb = $this->ReadAttributeString('Betriebsart') === Nachrichtenvertrag::BETRIEBSART_PEAK; @@ -317,12 +331,59 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell } elseif ($status['Ladestrom_A'] >= 3.5) { $this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', true); } + $this->aktualisiereFahrzeugstromgrenze($status); foreach ($status as $ident => $wert) { $this->setzeZustand($ident, $wert); } } + /** @param array $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->setzeSichtbareVariable('FahrzeugMaximalstrom', $kandidat); + $this->setzeFahrzeugstromKandidatZurueck(); + } + + private function setzeFahrzeugstromKandidatZurueck(): void + { + $this->WriteAttributeInteger('FahrzeugstromKandidat', 0); + $this->WriteAttributeInteger('FahrzeugstromKandidatSeit', 0); + } + /** @param list $angebot */ private function bestimmeWirksameSollleistung(array $angebot): int { @@ -332,6 +393,14 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell return $sollleistung; } $this->setzeZustand('SollwertGueltig', false); + $ersatz = 0; + foreach ($angebot as $leistungswert) { + if ($leistungswert > $sollleistung) { + break; + } + $ersatz = $leistungswert; + } + return $ersatz; } if (!$this->ReadAttributeBoolean('ZustandSolarladen') && $angebot !== [0]) { @@ -351,6 +420,7 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell return; } $this->WriteAttributeBoolean('LadeleistungSeitFreigabeErkannt', false); + $this->setzeFahrzeugstromKandidatZurueck(); $transport = new LadestationTransport( fn (array $anfrage): string => $this->fuehreHttpAnfrageAus($anfrage) @@ -586,7 +656,12 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell throw new InvalidArgumentException($property . ' muss mindestens 0 sein.'); } } - foreach (['Meldeintervall', 'VorgabeTimeout', 'Abfrageintervall'] as $property) { + foreach ([ + 'Meldeintervall', + 'VorgabeTimeout', + 'Abfrageintervall', + 'FahrzeugstromErkennungszeit', + ] as $property) { if ($this->ReadPropertyInteger($property) <= 0) { throw new InvalidArgumentException($property . ' muss groesser als 0 sein.'); } @@ -642,12 +717,26 @@ class LadestationStandAlone extends IPSModule implements VerbraucherSchnittstell $this->registriereVerbraucherDiagnose(); $this->RegisterVariableString('LetzterGeraetebefehl', 'Letzter Geraetebefehl', '', 200); $this->RegisterVariableString('LeistungsangebotDiagnose', 'Leistungsangebot', '', 210); + $this->RegisterVariableInteger( + 'FahrzeugMaximalstrom', + 'Erkannter Fahrzeug-Maximalstrom', + '', + 220 + ); + $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)); } } diff --git a/libs/LadestationAdapter.php b/libs/LadestationAdapter.php index 27d23e3..d280381 100644 --- a/libs/LadestationAdapter.php +++ b/libs/LadestationAdapter.php @@ -91,7 +91,7 @@ final class LadestationAdapter } /** - * @return array{Rohstatus: int, Istleistung_W: float} + * @return array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float} */ public static function dekodiereStatus(int $geraetetyp, string $antwort): array { @@ -110,12 +110,25 @@ 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['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; + $status['Iststrom_A'] = max( + (float) $daten['nrg'][4], + (float) $daten['nrg'][5], + (float) $daten['nrg'][6] + ) * $stromfaktor; + } + + return $status; } if ($geraetetyp === self::SMART_ME_PICO) { diff --git a/libs/LadestationRegler.php b/libs/LadestationRegler.php index 98ff067..5eddd13 100644 --- a/libs/LadestationRegler.php +++ b/libs/LadestationRegler.php @@ -32,7 +32,7 @@ final class LadestationRegler * 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} $rohstatus * @return array */ public static function normalisiereStatus( @@ -51,7 +51,9 @@ final class LadestationRegler 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; + $ladestrom = isset($rohstatus['Iststrom_A']) + ? max(0.0, (float) $rohstatus['Iststrom_A']) + : $leistung / $nenner; $ermittelterMaximalstrom = 2.5 + $ladestrom; $geladen = $warGeladen || ($warVerbunden @@ -80,6 +82,26 @@ 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) round($gemessenerStrom))); + if ($kandidat > $vorgegebenerStrom - 2 || $kandidat >= $konfigurierterMaximalstrom) { + return null; + } + + return $kandidat; + } + /** @return list */ public static function leistungsangebot( bool $aktiv, diff --git a/tests/LadestationAdapterTest.php b/tests/LadestationAdapterTest.php index 002b150..022c836 100644 --- a/tests/LadestationAdapterTest.php +++ b/tests/LadestationAdapterTest.php @@ -13,7 +13,7 @@ final class LadestationAdapterTest extends TestCase /** * @dataProvider statusFaelle * @param array $konfiguration - * @param array{Rohstatus: int, Istleistung_W: float} $erwartet + * @param array{Rohstatus: int, Istleistung_W: float, Iststrom_A?: float} $erwartet */ public function testStatusaufrufUndDekodierungFuerAlleGeraete( int $geraetetyp, @@ -47,16 +47,16 @@ 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, 'Iststrom_A' => 16.0], ]; 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, 'Iststrom_A' => 9.8], ]; yield 'Smart-me Pico' => [ LadestationAdapter::SMART_ME_PICO, @@ -161,9 +161,13 @@ final class LadestationAdapterTest extends TestCase ]; } - private function goEAntwort(int $status, float $leistung): string + /** @param list $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); diff --git a/tests/LadestationReglerTest.php b/tests/LadestationReglerTest.php index 19c8335..e8eb3e4 100644 --- a/tests/LadestationReglerTest.php +++ b/tests/LadestationReglerTest.php @@ -77,6 +77,24 @@ final class LadestationReglerTest extends TestCase 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(16, 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); diff --git a/tests/ManagerReglerTest.php b/tests/ManagerReglerTest.php index d9ba6b7..5356705 100644 --- a/tests/ManagerReglerTest.php +++ b/tests/ManagerReglerTest.php @@ -159,6 +159,34 @@ final class ManagerReglerTest extends TestCase 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 testFehlendeIstleistungFaelltAufAngenommenenSollwertZurueck(): void { $verbraucher = $this->verbraucher(10, 0, [3000], null, false); diff --git a/tests/Symcon/modules/LadestationStandAlone.php b/tests/Symcon/modules/LadestationStandAlone.php index dacba81..a1c122b 100644 --- a/tests/Symcon/modules/LadestationStandAlone.php +++ b/tests/Symcon/modules/LadestationStandAlone.php @@ -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', ], @@ -77,6 +80,23 @@ return static function (TestContext $test): void { ] ); + $stromerkennung = $test->createInstance( + '{0D94913C-0F31-4C29-A685-6EB4AE58E55D}', + 'Ladestation Fahrzeugstromerkennung', + [ + 'Geraetetyp' => 2, + 'Geraeteadresse' => '192.0.2.13', + 'MaximalerLadestrom' => 20, + 'FahrzeugstromErkennungszeit' => 1, + 'Ladefreigabe' => true, + 'Solarladen' => true, + 'EinstellungenInVisu' => true, + 'DiagnosevariablenAnzeigen' => true, + 'Testmodus' => true, + 'Testantwort' => $goEAntwort(2, 10263, [15.9, 15.8, 15.7]), + ] + ); + $managerId = $test->createInstance( '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', 'Hilfsmanager Ladestation', @@ -84,7 +104,7 @@ 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]) ), JSON_THROW_ON_ERROR), ] ); @@ -161,7 +181,7 @@ return static function (TestContext $test): void { GetValue($test->objectByIdent('FahrzeugGeladen', $solarPause)) ); - IPS_SetProperty($solarPause, 'Testantwort', $goEAntwort(2, 2300)); + IPS_SetProperty($solarPause, 'Testantwort', $goEAntwort(2, 2300, [100, 100, 100])); IPS_ApplyChanges($solarPause); $test->assertSame( @@ -229,6 +249,30 @@ return static function (TestContext $test): void { ), '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, 13850); + IPS_RequestAction($stromerkennung, 'Statuszyklus', false); + IPS_Sleep(2100); + IPS_RequestAction($stromerkennung, 'Statuszyklus', false); + + $test->assertSame( + 16, + GetValue($test->objectByIdent('FahrzeugMaximalstrom', $stromerkennung)) + ); + $test->assertSame( + '[0,4150,4850,5550,6250,6950,7600,8300,9000,9700,10300,11000]', + (string) GetValue($test->objectByIdent('LeistungsangebotDiagnose', $stromerkennung)) + ); + $test->assertTrue(str_contains( + (string) GetValue($test->objectByIdent('LetzterGeraetebefehl', $stromerkennung)), + 'amp=16' + )); + }); + $test->runCase('Sollwert bleibt waehrend der Ladeanlaufzeit gueltig', static function ( TestContext $test ) use ($goEAntwort, $stationen, $anforderungSenden): void {