feat(batterie): stufenlose Leistungsbereiche unterstützen
Tests / test (push) Successful in 49s

This commit is contained in:
dh
2026-09-27 08:46:17 +00:00
parent 72d96aeb81
commit f47c908798
10 changed files with 132 additions and 91 deletions
+6 -4
View File
@@ -8,7 +8,7 @@ IP-Symcon-Registervariablen.
## Funktionen ## Funktionen
- positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen - positive Leistung bedeutet Laden, negative Leistung bedeutet Entladen
- 250-W-Grundraster mit 50-W-Feinwerten zwischen -200 W und +200 W - stufenlose Leistungsbereiche in ganzen Watt innerhalb der dynamischen Grenzen
- unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb - unterschiedliche Leistungsangebote fuer PV- und Peakbetrieb
- Reserve- und Mindestladezustand mit konfigurierbarer Hysterese - Reserve- und Mindestladezustand mit konfigurierbarer Hysterese
- direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe, - direkte Registeransteuerung fuer herstellerunabhaengige Batterien, GoodWe,
@@ -86,9 +86,11 @@ LadezustandHysterese ersetzt die bisher fest im Code hinterlegte
Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten: Die bisherigen betriebsartabhaengigen Angebote bleiben erhalten:
- Im PV-Betrieb steht oberhalb der Reserve das gesamte Raster zur Verfuegung. - Im PV-Betrieb steht oberhalb der Reserve der durchgaengige Bereich von der
- Unterhalb der Reserve wird die maximale Ladeleistung angeboten. maximalen Entlade- bis zur maximalen Ladeleistung zur Verfuegung.
- Bei vollem Speicher werden nur Entladung und 0 angeboten. - Unterhalb der Reserve wird die maximale Ladeleistung 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 - Im Peakbetrieb wird oberhalb der Reserve der aus Netz- und aktueller
Batterieleistung berechnete Entladewert angeboten. Batterieleistung berechnete Entladewert angeboten.
- Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden - Innerhalb der Reserve-Hysterese und unterhalb des Mindestladezustands werden
+10 -9
View File
@@ -293,10 +293,9 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
$sollleistung = $daten['Sollleistung_W']; $sollleistung = $daten['Sollleistung_W'];
if ($sollleistung === null) { if ($sollleistung === null) {
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array( && !Nachrichtenvertrag::enthaeltLeistungswert(
(int) $this->leseZustand('Sollleistung'),
$this->leseLeistungsangebot(), $this->leseLeistungsangebot(),
true (int) $this->leseZustand('Sollleistung')
) )
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->setzeZustand('SollwertGueltig', false);
@@ -306,7 +305,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
return; return;
} }
if (!in_array($sollleistung, $this->leseLeistungsangebot(), true)) { if (!Nachrichtenvertrag::enthaeltLeistungswert(
$this->leseLeistungsangebot(),
$sollleistung
)) {
throw new InvalidArgumentException( throw new InvalidArgumentException(
'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.' 'Sollleistung_W liegt nicht im aktuell gemeldeten Leistungsangebot.'
); );
@@ -394,10 +396,9 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
); );
if ((bool) $this->leseZustand('SollwertGueltig') if ((bool) $this->leseZustand('SollwertGueltig')
&& !in_array( && !Nachrichtenvertrag::enthaeltLeistungswert(
(int) $this->leseZustand('Sollleistung'),
$angebot, $angebot,
true (int) $this->leseZustand('Sollleistung')
) )
) { ) {
$this->setzeZustand('SollwertGueltig', false); $this->setzeZustand('SollwertGueltig', false);
@@ -1073,7 +1074,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
} }
} }
/** @return list<int> */ /** @return list<int|array{Von_W: int, Bis_W: int}> */
private function leseLeistungsangebot(): array private function leseLeistungsangebot(): array
{ {
$angebot = json_decode( $angebot = json_decode(
@@ -1086,7 +1087,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle
return [0]; return [0];
} }
return array_values(array_map('intval', $angebot)); return array_values($angebot);
} }
private function setzeStoerung(string $text): void private function setzeStoerung(string $text): void
+8 -6
View File
@@ -29,8 +29,8 @@ Sollleistung_W verwendet folgende Semantik:
- null: nur Betriebsart synchronisieren und neues Angebot anfordern - null: nur Betriebsart synchronisieren und neues Angebot anfordern
Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten Ein konkreter Wert muss im zuletzt fuer dieselbe Betriebsart gemeldeten
Leistungsangebot enthalten sein. Waehren der Aenderungssperre wird nur die Einzelwert oder Leistungsbereich enthalten sein. Waehrend der Aenderungssperre
Wiederholung des aktuellen Sollwerts akzeptiert. wird nur die Wiederholung des aktuellen Sollwerts akzeptiert.
## Batterie an Manager ## Batterie an Manager
@@ -56,10 +56,10 @@ Beispiel:
"EmpfaengerID": 10001, "EmpfaengerID": 10001,
"Zeitpunkt": 1788825602 "Zeitpunkt": 1788825602
}, },
"Betriebsart": "Peak", "Betriebsart": "PV",
"PrioritaetPV": 0, "PrioritaetPV": 0,
"PrioritaetPeak": 0, "PrioritaetPeak": 0,
"Leistungswerte_W": [-1500], "Leistungswerte_W": [{"Von_W": -5000, "Bis_W": 5000}],
"AenderungMoeglich": true, "AenderungMoeglich": true,
"Verfuegbar": true, "Verfuegbar": true,
"Istleistung_W": -250.0, "Istleistung_W": -250.0,
@@ -68,7 +68,7 @@ Beispiel:
{ {
"Kennung": "Sollleistung_W", "Kennung": "Sollleistung_W",
"Art": "Sollwert", "Art": "Sollwert",
"Wert": -1500, "Wert": -537,
"Einheit": "W" "Einheit": "W"
}, },
{ {
@@ -83,7 +83,9 @@ Beispiel:
Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und Ist die Leistungsmessung ungueltig, wird Istleistung_W als null und
Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist Leistungsquelle als 0 gemeldet. Bei einem gueltigen Messwert ist
Leistungsquelle 2. Leistungsquelle 2. Frei regelbare Lade- und Entladeangebote werden als
inklusive Bereiche in ganzen Watt gemeldet. Feste Schutz- und Peakvorgaben
bleiben einzelne Leistungswerte.
## Ereignisse ## Ereignisse
+1 -1
View File
@@ -11,7 +11,6 @@ Verhaltensreferenz.
- positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen - positive Sollleistung fuer Laden und negative Sollleistung fuer Entladen
- dynamische maximale Lade- und Entladeleistung aus Variablen - dynamische maximale Lade- und Entladeleistung aus Variablen
- Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben - Ladezustand, Netzleistung und aktuelle Batterieleistung als Eingaben
- 250-W-Grundraster und 50-W-Feinwerte um 0
- Reserve-, Mindestladezustands- und Hystereselogik - Reserve-, Mindestladezustands- und Hystereselogik
- getrennte Angebote fuer PV und Peak - getrennte Angebote fuer PV und Peak
- Wechselrichter- oder Enelix-Steuerung - Wechselrichter- oder Enelix-Steuerung
@@ -25,6 +24,7 @@ Verhaltensreferenz.
| zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse | | zyklischer Timer Do_UserCalc | Messwert- und Managerereignisse |
| IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer | | IdleCounter in Zyklen | Aenderungssperre in Sekunden mit Einmaltimer |
| PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag | | PowerSteps als Modulvariable | Leistungswerte_W direkt im Vertrag |
| 250-W-Grundraster mit 50-W-Feinwerten | inklusive Leistungsbereiche in ganzen Watt |
| interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction | | interne Stellwertvariablen | ausgewaehlte Registervariablen mit RequestAction |
| fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl | | fest verdrahtete Herstellerhilfsvariablen | dynamisch sichtbare Registerauswahl |
| fest codierte 2-%-Hysterese | Property LadezustandHysterese | | fest codierte 2-%-Hysterese | Property LadezustandHysterese |
+11 -7
View File
@@ -73,13 +73,17 @@ zuletzt Management geschrieben.
## Leistungsangebot ## Leistungsangebot
Das Raster besteht wie in Enelix 1 aus 250-W-Grundschritten. Um 0 werden Die Batterie meldet innerhalb der dynamischen Lade- und Entladegrenzen
zusaetzlich -200, -150, -100, -50, 0, 50, 100, 150 und 200 W angeboten, inklusive Leistungsbereiche in ganzen Watt. Der Manager kann dadurch jeden
soweit diese Werte innerhalb der dynamischen Leistungsgrenzen liegen. ganzzahligen Sollwert innerhalb des aktuell erlaubten Bereichs vorgeben. Die
Grenzen werden abgerundet, damit keine dynamische Maximalleistung ueberschritten
wird.
PV und Peak verwenden die bisherige SoC-Logik. Die Hysterese wird als PV und Peak verwenden weiterhin die bisherige SoC-Logik. Feste Schutz- oder
persistenter Modulzustand gefuehrt. Bei Wechselrichtersteuerung, lokaler Peakvorgaben bleiben Einzelwerte; frei regelbare Angebote werden als Bereiche
Deaktivierung oder ungueltigen Messwerten lautet das Angebot [0]. gemeldet. Die Hysterese wird als persistenter Modulzustand gefuehrt. Bei
Wechselrichtersteuerung, lokaler Deaktivierung oder ungueltigen Messwerten
lautet das Angebot [0].
## Variablen ## Variablen
@@ -115,7 +119,7 @@ Transport und gemeinsame Felder folgen
## Tests ## Tests
- BatterieReglerTest: Raster, Hysterese, PV/Peak und Herstellerabbildung - BatterieReglerTest: Leistungsbereiche, Hysterese, PV/Peak und Herstellerabbildung
- BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID - BatterieModulstrukturTest: Metadaten, Formular, Ereignismodell und Manager-ID
- Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter - Symcon/modules/Batterie.php: reale Modulinstanz und Registeraktionen unter
IP-Symcon 8.0 IP-Symcon 8.0
+23 -48
View File
@@ -17,38 +17,6 @@ final class BatterieRegler
public const STEUERUNG_WECHSELRICHTER = 1; public const STEUERUNG_WECHSELRICHTER = 1;
public const STEUERUNG_ENELIX = 2; public const STEUERUNG_ENELIX = 2;
/**
* Erzeugt das bisherige Raster: 250-W-Grundschritte und
* 50-W-Feinschritte im Bereich von -200 W bis +200 W.
*
* @return list<int>
*/
public static function leistungsraster(float $maxLadenW, float $maxEntladenW): array
{
self::pruefeLeistungsgrenzen($maxLadenW, $maxEntladenW);
$maxLaden = (int) floor($maxLadenW / 50.0) * 50;
$maxEntladen = (int) floor($maxEntladenW / 50.0) * 50;
$werte = [0];
for ($leistung = -$maxEntladen; $leistung <= 0; $leistung += 250) {
$werte[] = $leistung;
}
for ($leistung = 0; $leistung <= $maxLaden; $leistung += 250) {
$werte[] = $leistung;
}
for ($leistung = -200; $leistung <= 200; $leistung += 50) {
if ($leistung >= -$maxEntladen && $leistung <= $maxLaden) {
$werte[] = $leistung;
}
}
$werte = array_values(array_unique($werte));
sort($werte, SORT_NUMERIC);
return $werte;
}
public static function hystereseAktiv( public static function hystereseAktiv(
float $ladezustand, float $ladezustand,
float $reserve, float $reserve,
@@ -68,9 +36,10 @@ final class BatterieRegler
} }
/** /**
* Bildet das zustands- und betriebsartabhaengige Angebot aus Enelix 1 ab. * Bildet das zustands- und betriebsartabhaengige Angebot als
* durchgaengige Leistungsbereiche in ganzen Watt ab.
* *
* @return list<int> * @return list<int|array{Von_W: int, Bis_W: int}>
*/ */
public static function leistungsangebot( public static function leistungsangebot(
bool $aktiv, bool $aktiv,
@@ -97,7 +66,8 @@ final class BatterieRegler
return [0]; return [0];
} }
$raster = self::leistungsraster($maxLadenW, $maxEntladenW); $maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($maxEntladenW);
if ($peakbetrieb) { if ($peakbetrieb) {
if ($ladezustand > $reserve && !$hystereseAktiv) { if ($ladezustand > $reserve && !$hystereseAktiv) {
$ziel = $netzleistungW > 0.0 $ziel = $netzleistungW > 0.0
@@ -107,26 +77,26 @@ final class BatterieRegler
return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)]; return [self::begrenzeLeistung($ziel, $maxLadenW, $maxEntladenW)];
} }
if ($ladezustand > $reserve && $hystereseAktiv) { if ($ladezustand > $reserve && $hystereseAktiv) {
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0); return self::leistungsbereich(-$maxEntladen, 0);
} }
if ($ladezustand > $minimum) { if ($ladezustand > $minimum) {
return $raster; return self::leistungsbereich(-$maxEntladen, $maxLaden);
} }
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0); return self::leistungsbereich(0, $maxLaden);
} }
if ($ladezustand > 99.0) { if ($ladezustand > 99.0) {
return self::filtere($raster, static fn (int $wert): bool => $wert <= 0); return self::leistungsbereich(-$maxEntladen, 0);
} }
if ($ladezustand > $reserve && !$hystereseAktiv) { if ($ladezustand > $reserve && !$hystereseAktiv) {
return $raster; return self::leistungsbereich(-$maxEntladen, $maxLaden);
} }
if ($ladezustand >= $reserve && $hystereseAktiv) { if ($ladezustand >= $reserve && $hystereseAktiv) {
return self::filtere($raster, static fn (int $wert): bool => $wert >= 0); return self::leistungsbereich(0, $maxLaden);
} }
return [(int) round($maxLadenW)]; return [$maxLaden];
} }
/** /**
@@ -220,12 +190,14 @@ final class BatterieRegler
return 1; return 1;
} }
/** @param list<int> $werte /** @return list<int|array{Von_W: int, Bis_W: int}> */
* @return list<int> private static function leistungsbereich(int $vonW, int $bisW): array
*/
private static function filtere(array $werte, callable $filter): array
{ {
return array_values(array_filter($werte, $filter)); if ($vonW === $bisW) {
return [$vonW];
}
return [['Von_W' => $vonW, 'Bis_W' => $bisW]];
} }
private static function begrenzeLeistung( private static function begrenzeLeistung(
@@ -233,7 +205,10 @@ final class BatterieRegler
float $maxLadenW, float $maxLadenW,
float $maxEntladenW float $maxEntladenW
): int { ): int {
return (int) round(min($maxLadenW, max(-$maxEntladenW, $leistungW))); $maxLaden = (int) floor($maxLadenW);
$maxEntladen = (int) floor($maxEntladenW);
return (int) round(min($maxLaden, max(-$maxEntladen, $leistungW)));
} }
private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void private static function pruefeLeistungsgrenzen(float $maxLadenW, float $maxEntladenW): void
+27
View File
@@ -70,6 +70,33 @@ final class Nachrichtenvertrag
self::pruefeZustand($daten['Zustand']); self::pruefeZustand($daten['Zustand']);
} }
/**
* @param list<int|array{Von_W: int, Bis_W: int}> $leistungswerte
*/
public static function enthaeltLeistungswert(array $leistungswerte, int $leistung): bool
{
foreach ($leistungswerte as $eintrag) {
if (is_int($eintrag)) {
if ($eintrag === $leistung) {
return true;
}
continue;
}
if (is_array($eintrag)
&& isset($eintrag['Von_W'], $eintrag['Bis_W'])
&& is_int($eintrag['Von_W'])
&& is_int($eintrag['Bis_W'])
&& $leistung >= $eintrag['Von_W']
&& $leistung <= $eintrag['Bis_W']
) {
return true;
}
}
return false;
}
/** /**
* @param mixed $kopf * @param mixed $kopf
*/ */
+31 -12
View File
@@ -10,14 +10,14 @@ use PHPUnit\Framework\TestCase;
final class BatterieReglerTest extends TestCase final class BatterieReglerTest extends TestCase
{ {
public function testLeistungsrasterBehaeltGrobeUndFeineSchritte(): void public function testLeistungsbereichBleibtInnerhalbDynamischerGrenzen(): void
{ {
self::assertSame( self::assertSame(
[ [['Von_W' => -999, 'Bis_W' => 1000]],
-1000, -750, -500, -250, -200, -150, -100, -50, BatterieRegler::leistungsangebot(
0, 50, 100, 150, 200, 250, 500, 750, 1000, true, true, false, 50.0, 20.0, 10.0, false,
], 0.0, 0.0, 1000.9, 999.9
BatterieRegler::leistungsraster(1000.0, 1000.0) )
); );
} }
@@ -28,12 +28,10 @@ final class BatterieReglerTest extends TestCase
self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true)); self::assertFalse(BatterieRegler::hystereseAktiv(22.1, 20.0, 2.0, true));
} }
public function testPvAngebotEntsprichtDemBisherigenSocVerhalten(): void public function testPvAngebotVerwendetStufenloseBereicheMitBisherigerSocLogik(): void
{ {
$voll = BatterieRegler::leistungsraster(1000.0, 1000.0);
self::assertSame( self::assertSame(
$voll, [['Von_W' => -1000, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot( BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false, true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0 0.0, 0.0, 1000.0, 1000.0
@@ -47,7 +45,7 @@ final class BatterieReglerTest extends TestCase
) )
); );
self::assertSame( self::assertSame(
[-1000, -750, -500, -250, -200, -150, -100, -50, 0], [['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot( BatterieRegler::leistungsangebot(
true, true, false, 100.0, 20.0, 10.0, false, true, true, false, 100.0, 20.0, 10.0, false,
0.0, 0.0, 1000.0, 1000.0 0.0, 0.0, 1000.0, 1000.0
@@ -66,6 +64,24 @@ final class BatterieReglerTest extends TestCase
); );
} }
public function testPeakEinschraenkungenWerdenAlsBereicheGemeldet(): void
{
self::assertSame(
[['Von_W' => -1000, 'Bis_W' => 0]],
BatterieRegler::leistungsangebot(
true, true, true, 21.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
self::assertSame(
[['Von_W' => 0, 'Bis_W' => 1000]],
BatterieRegler::leistungsangebot(
true, true, true, 10.0, 20.0, 10.0, true,
0.0, 0.0, 1000.0, 1000.0
)
);
}
public function testWechselrichtersteuerungBietetNurNull(): void public function testWechselrichtersteuerungBietetNurNull(): void
{ {
self::assertSame( self::assertSame(
@@ -186,6 +202,9 @@ final class BatterieReglerTest extends TestCase
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
BatterieRegler::leistungsraster(-1.0, 1000.0); BatterieRegler::leistungsangebot(
true, true, false, 50.0, 20.0, 10.0, false,
0.0, 0.0, -1.0, 1000.0
);
} }
} }
+11
View File
@@ -43,6 +43,17 @@ final class NachrichtenvertragTest extends TestCase
self::assertTrue(true); self::assertTrue(true);
} }
public function testLeistungswertWirdInEinzelwertenUndBereichenGefunden(): void
{
$angebot = [0, ['Von_W' => 1000, 'Bis_W' => 2000], 3000];
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 0));
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 1537));
self::assertTrue(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2000));
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 999));
self::assertFalse(Nachrichtenvertrag::enthaeltLeistungswert($angebot, 2500));
}
public function testNegativePrioritaetWirdAbgelehnt(): void public function testNegativePrioritaetWirdAbgelehnt(): void
{ {
$daten = $this->verbraucherdaten(); $daten = $this->verbraucherdaten();
+4 -4
View File
@@ -119,10 +119,10 @@ PHP
): void { ): void {
IPS_RequestAction($batterieID, 'Aktiv', true); IPS_RequestAction($batterieID, 'Aktiv', true);
$managerdatenSenden('PV', null); $managerdatenSenden('PV', null);
$managerdatenSenden('PV', 500); $managerdatenSenden('PV', 537);
$test->waitUntil( $test->waitUntil(
static fn (): bool => (float) GetValue($leistungID) === 500.0 static fn (): bool => (float) GetValue($leistungID) === 537.0
&& (int) GetValue($modusID) === 11 && (int) GetValue($modusID) === 11
&& (int) GetValue($managementID) === 2 && (int) GetValue($managementID) === 2
); );
@@ -136,9 +136,9 @@ PHP
$modusID, $modusID,
$leistungID $leistungID
): void { ): void {
$managerdatenSenden('PV', -500); $managerdatenSenden('PV', -843);
$test->waitUntil( $test->waitUntil(
static fn (): bool => (float) GetValue($leistungID) === 500.0 static fn (): bool => (float) GetValue($leistungID) === 843.0
&& (int) GetValue($modusID) === 12 && (int) GetValue($modusID) === 12
); );
} }