From 58f91e431936fedca3e59178037b939f420ce3e3 Mon Sep 17 00:00:00 2001 From: dh_Agent Date: Tue, 6 Oct 2026 11:55:26 +0000 Subject: [PATCH] feat(manager): distribute equal-priority power stepwise --- CHANGELOG.md | 8 ++ docs/module/Manager/README.md | 39 ++++++-- libs/ManagerRegler.php | 103 ++++++++++++++++++-- tests/ManagerReglerTest.php | 173 +++++++++++++++++++++++++++++++++- 4 files changed, 304 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a316bca..050e284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 46357eb..2993dbd 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -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 diff --git a/libs/ManagerRegler.php b/libs/ManagerRegler.php index 579b2c8..36cf412 100644 --- a/libs/ManagerRegler.php +++ b/libs/ManagerRegler.php @@ -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> $gruppe Verbraucher derselben Prioritaet + * @param array $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> $gruppe + * @param array $sollwerte + * @return list> + */ + 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 $daten */ diff --git a/tests/ManagerReglerTest.php b/tests/ManagerReglerTest.php index 5164685..ad51bf9 100644 --- a/tests/ManagerReglerTest.php +++ b/tests/ManagerReglerTest.php @@ -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