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