diff --git a/Manager/form.json b/Manager/form.json index 90ef5ab..b9ad855 100644 --- a/Manager/form.json +++ b/Manager/form.json @@ -127,6 +127,36 @@ ], "values": [] }, + { + "type": "Label", + "caption": "Peak Shaving am Netzanschlusspunkt" + }, + { + "type": "CheckBox", + "name": "EinspeisebegrenzungAktiv", + "caption": "Anlagenweite Einspeisebegrenzung aktivieren" + }, + { + "type": "NumberSpinner", + "name": "Einspeisegrenze", + "caption": "Maximale Einspeisung der Gesamtanlage", + "suffix": " W", + "minimum": 0.0, + "digits": 1 + }, + { + "type": "NumberSpinner", + "name": "Einspeisetoleranz", + "caption": "Regeltoleranz", + "suffix": " W", + "minimum": 0.0, + "digits": 1 + }, + { + "type": "CheckBox", + "name": "NetzfahrplanAktiv", + "caption": "Intelligenten Netzfahrplan zur Vermeidung von Abregelung verwenden" + }, { "type": "CheckBox", "name": "AutomatischeSuche", @@ -283,6 +313,33 @@ "minimum": 0.0, "digits": 3 } + }, + { + "caption": "Istleistung", + "name": "IstleistungVariableID", + "width": "140px", + "add": 0, + "edit": {"type": "SelectVariable"} + }, + { + "caption": "PV-Begrenzung", + "name": "BegrenzungVariableID", + "width": "150px", + "add": 0, + "edit": {"type": "SelectVariable"} + }, + { + "caption": "Stellwert", + "name": "Begrenzungsart", + "width": "120px", + "add": "percent", + "edit": { + "type": "Select", + "options": [ + {"caption": "Prozent", "value": "percent"}, + {"caption": "Watt", "value": "watt"} + ] + } } ] }, diff --git a/Manager/module.php b/Manager/module.php index e2587e2..1a77069 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -5,10 +5,12 @@ declare(strict_types=1); require_once __DIR__ . '/../libs/ManagerSchnittstelle.php'; require_once __DIR__ . '/../libs/Nachrichtenvertrag.php'; require_once __DIR__ . '/../libs/ManagerRegler.php'; +require_once __DIR__ . '/../libs/EinspeiseRegler.php'; require_once __DIR__ . '/../libs/Lizenzpruefung.php'; require_once __DIR__ . '/../libs/Anlagentopologie.php'; use Belevo\EnelixEMS\Anlagentopologie; +use Belevo\EnelixEMS\EinspeiseRegler; use Belevo\EnelixEMS\Lizenzpruefung; use Belevo\EnelixEMS\ManagerRegler; use Belevo\EnelixEMS\ManagerSchnittstelle; @@ -23,6 +25,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate'; private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology'; private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry'; + private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule'; private const LIZENZ_RETRY_SEKUNDEN = 3600; private const VM_UPDATE = 10603; private const VERBRAUCHER_LIZENZEN = [ @@ -62,6 +65,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle private const DIAGNOSE_VARIABLEN = [ 'NetzleistungGueltig', 'WirksameLastspitzengrenze', + 'WirksameEinspeisegrenze', + 'Abregelbedarf', + 'Wechselrichterstatus', 'Verteilbudget', 'VerbraucherAnzahl', 'VerbraucherVerfuegbar', @@ -95,6 +101,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen()); $this->RegisterPropertyFloat('SollwertSolarladen', 0.0); $this->RegisterPropertyFloat('Umschaltdifferenz', 5.0); + $this->RegisterPropertyBoolean('EinspeisebegrenzungAktiv', false); + $this->RegisterPropertyFloat('Einspeisegrenze', 0.0); + $this->RegisterPropertyFloat('Einspeisetoleranz', 100.0); + $this->RegisterPropertyBoolean('NetzfahrplanAktiv', false); $this->RegisterPropertyString('AnlagenWechselrichter', '[]'); $this->RegisterPropertyString('AnlagenPVFlaechen', '[]'); $this->RegisterPropertyString('AnlagenBatterien', '[]'); @@ -118,6 +128,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->RegisterAttributeString('VerbraucherCache', '{}'); $this->RegisterAttributeString('LetzteSollwerte', '{}'); + $this->RegisterAttributeString('LetztePvGrenzen', '{}'); + $this->RegisterAttributeString('Netzfahrplan', '{}'); $this->RegisterAttributeInteger('RegistrierteNetzleistungVariableID', 0); $this->RegisterAttributeString('LizenzInstallationID', ''); $this->RegisterAttributeString('LizenzLease', '{}'); @@ -208,6 +220,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle $automatisch = $this->ReadPropertyBoolean('AutomatischeSuche'); $lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus'); $peakShavingLizenziert = $this->peakShavingLizenziert(); + $netzfahrplanLizenziert = $this->berechtigungLizenziert( + Lizenzpruefung::NETZFAHRPLAN + ); $monatsgrenzenSichtbar = $peakShavingLizenziert && $lastspitzenmodus === 2 && $this->GetBuffer('MonatsgrenzenSichtbar') === '1'; @@ -251,6 +266,20 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'options', $lastspitzenOptionen ); + foreach (['EinspeisebegrenzungAktiv', 'Einspeisegrenze', 'Einspeisetoleranz'] as $feld) { + $this->setzeFormularParameter( + $formular['elements'], + $feld, + 'enabled', + $peakShavingLizenziert + ); + } + $this->setzeFormularParameter( + $formular['elements'], + 'NetzfahrplanAktiv', + 'enabled', + $peakShavingLizenziert && $netzfahrplanLizenziert + ); $this->setzeFormularParameter( $formular['elements'], 'Lastspitzengrenze', @@ -458,6 +487,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle $aktiv = (bool) $this->GetValue('Aktiv'); if (!$aktiv || !$netzleistungGueltig) { + if (!$aktiv) { + try { + $this->gibEinspeisebegrenzungFrei(); + } catch (Throwable $fehler) { + $this->setzeDiagnosewert( + 'Wechselrichterstatus', + 'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500) + ); + } + } $this->SetValue('Betriebsart', ManagerRegler::BETRIEBSART_INAKTIV); $this->setzeDiagnosewert('Verteilbudget', 0.0); $this->setzeNichtGeregelteVerbraucher( @@ -499,6 +538,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle $synchronisiert ); $this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0); + $this->regleEinspeisung($netzleistung, $stoerungen, $erneuern); if (!$synchronisiert) { $this->setzeDiagnosewert('Verteilbudget', 0.0); $this->setzeDiagnosewert('Verbraucherstatus', json_encode([ @@ -516,9 +556,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle return; } + $fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe(); $zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK ? (float) $lastspitzengrenze - : $this->ReadPropertyFloat('SollwertSolarladen'); + : ($fahrplanVorgabe ?? $this->ReadPropertyFloat('SollwertSolarladen')); $ergebnis = ManagerRegler::verteile( $netzleistung, $zielnetzleistung, @@ -556,6 +597,174 @@ class Manager extends IPSModule implements ManagerSchnittstelle $this->SetStatus(self::STATUS_AKTIV); } + /** + * @param list $stoerungen + */ + private function regleEinspeisung( + float $netzleistung, + array &$stoerungen, + bool $erneuern + ): void { + if (!$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) { + try { + $this->gibEinspeisebegrenzungFrei(); + $this->setzeDiagnosewert('Wechselrichterstatus', 'Nicht aktiv'); + } catch (Throwable $fehler) { + $stoerungen[] = 'Einspeisebegrenzung zuruecksetzen: ' . $fehler->getMessage(); + $this->setzeDiagnosewert( + 'Wechselrichterstatus', + 'Ruecksetzen fehlgeschlagen: ' . substr($fehler->getMessage(), 0, 500) + ); + } + $this->setzeDiagnosewert('WirksameEinspeisegrenze', 0.0); + $this->setzeDiagnosewert('Abregelbedarf', 0.0); + return; + } + + try { + $wechselrichter = $this->regelbareWechselrichter(); + $letzteGrenzen = $this->leseJsonAttribut('LetztePvGrenzen'); + $ergebnis = EinspeiseRegler::verteile( + $netzleistung, + $this->ReadPropertyFloat('Einspeisegrenze'), + $this->ReadPropertyFloat('Einspeisetoleranz'), + $wechselrichter, + $letzteGrenzen + ); + $konfiguration = []; + foreach ($wechselrichter as $eintrag) { + $konfiguration[$eintrag['ID']] = $eintrag; + } + foreach ($ergebnis['Grenzen_W'] as $id => $grenzeW) { + if (!$erneuern + && isset($letzteGrenzen[$id]) + && abs((float) $letzteGrenzen[$id] - $grenzeW) < 0.5 + ) { + continue; + } + $eintrag = $konfiguration[$id]; + $stellwert = EinspeiseRegler::stellwert( + $grenzeW, + (float) $eintrag['Nennleistung_W'], + $eintrag['Begrenzungsart'] + ); + RequestAction($eintrag['BegrenzungVariableID'], $stellwert); + } + $this->WriteAttributeString( + 'LetztePvGrenzen', + json_encode($ergebnis['Grenzen_W'], JSON_THROW_ON_ERROR) + ); + $this->setzeDiagnosewert( + 'WirksameEinspeisegrenze', + $this->ReadPropertyFloat('Einspeisegrenze') + ); + $this->setzeDiagnosewert('Abregelbedarf', $ergebnis['Abregelbedarf_W']); + $this->setzeDiagnosewert( + 'Wechselrichterstatus', + json_encode($ergebnis, JSON_THROW_ON_ERROR) + ); + } catch (Throwable $fehler) { + $stoerungen[] = 'Einspeisebegrenzung: ' . $fehler->getMessage(); + $this->setzeDiagnosewert( + 'Wechselrichterstatus', + 'Fehler: ' . substr($fehler->getMessage(), 0, 500) + ); + } + } + + private function gibEinspeisebegrenzungFrei(): void + { + if ($this->leseJsonAttribut('LetztePvGrenzen') === []) { + return; + } + foreach ($this->regelbareWechselrichter() as $eintrag) { + RequestAction( + $eintrag['BegrenzungVariableID'], + EinspeiseRegler::stellwert( + (float) $eintrag['Nennleistung_W'], + (float) $eintrag['Nennleistung_W'], + $eintrag['Begrenzungsart'] + ) + ); + } + $this->WriteAttributeString('LetztePvGrenzen', '{}'); + } + + /** + * @return list + */ + private function regelbareWechselrichter(): array + { + $liste = json_decode( + $this->ReadPropertyString('AnlagenWechselrichter'), + true, + 512, + JSON_THROW_ON_ERROR + ); + if (!is_array($liste)) { + throw new InvalidArgumentException('AnlagenWechselrichter muss eine JSON-Liste sein.'); + } + + $ergebnis = []; + foreach ($liste as $index => $eintrag) { + if (!is_array($eintrag) + || !in_array($eintrag['Typ'] ?? null, ['pv', 'hybrid'], true) + ) { + continue; + } + $istID = $eintrag['IstleistungVariableID'] ?? 0; + $begrenzungID = $eintrag['BegrenzungVariableID'] ?? 0; + if ($istID === 0 && $begrenzungID === 0) { + continue; + } + if (!is_int($istID) || !is_int($begrenzungID) + || $istID <= 0 || $begrenzungID <= 0 + || !IPS_VariableExists($istID) || !IPS_VariableExists($begrenzungID) + ) { + throw new InvalidArgumentException( + 'Wechselrichter ' . ($index + 1) . ': Istleistung und Begrenzungsregister fehlen.' + ); + } + $begrenzungsvariable = IPS_GetVariable($begrenzungID); + if ((int) ($begrenzungsvariable['VariableAction'] ?? 0) <= 0 + && (int) ($begrenzungsvariable['VariableCustomAction'] ?? 0) <= 0 + ) { + throw new InvalidArgumentException( + 'Wechselrichter ' . ($index + 1) . ': Begrenzungsregister hat keine Aktion.' + ); + } + $istleistung = GetValue($istID); + $nennleistung = $eintrag['ACNennleistung_kW'] ?? null; + $art = $eintrag['Begrenzungsart'] ?? 'percent'; + if ((!is_int($istleistung) && !is_float($istleistung)) + || (!is_int($nennleistung) && !is_float($nennleistung)) + || !in_array($art, ['percent', 'watt'], true) + ) { + throw new InvalidArgumentException( + 'Wechselrichter ' . ($index + 1) . ': Regelparameter sind ungueltig.' + ); + } + $ergebnis[] = [ + 'ID' => (string) ($eintrag['ID'] ?? ''), + 'Nennleistung_W' => (float) $nennleistung * 1000.0, + 'Istleistung_W' => max(0.0, (float) $istleistung), + 'BegrenzungVariableID' => $begrenzungID, + 'Begrenzungsart' => $art, + ]; + } + if ($ergebnis === []) { + throw new InvalidArgumentException('Kein PV-Wechselrichter mit Stellregister konfiguriert.'); + } + + return $ergebnis; + } + /** @return array{0: float, 1: bool} */ private function leseNetzleistung(): array { @@ -1034,6 +1243,19 @@ class Manager extends IPSModule implements ManagerSchnittstelle '', 50 ); + $this->RegisterVariableFloat( + 'WirksameEinspeisegrenze', + 'Wirksame Einspeisegrenze', + '', + 52 + ); + $this->RegisterVariableFloat('Abregelbedarf', 'Abregelbedarf', '', 54); + $this->RegisterVariableString( + 'Wechselrichterstatus', + 'Wechselrichterstatus', + '', + 56 + ); $this->RegisterVariableFloat('Verteilbudget', 'Verteilbudget', '', 60); $this->RegisterVariableInteger('VerbraucherAnzahl', 'Verbraucher Anzahl', '', 70); $this->RegisterVariableInteger( @@ -1114,20 +1336,23 @@ class Manager extends IPSModule implements ManagerSchnittstelle } $lease = $this->leseLizenzLease(); $codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash); - $lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus'); + $lastspitzenmodus = $this->lizenzPeakModus(); $basisGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, 0 ); - $gueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( + $leaseGueltig = $codePasst && Lizenzpruefung::istLeaseGueltig( $lease, $installationID, $lastspitzenmodus ); - $berechtigungenVeraltet = $basisGueltig - && !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); - $geraetezugangFehlt = $this->hatAnlagentopologie() + $berechtigungenPassen = $leaseGueltig + && $this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus); + $gueltig = $leaseGueltig && $berechtigungenPassen; + $berechtigungenVeraltet = $basisGueltig && !$berechtigungenPassen; + $geraetezugangFehlt = ($this->hatAnlagentopologie() + || $this->prognoseTelemetrieKonfiguriert()) && $this->ReadAttributeString('PrognoseInstallationsToken') === ''; $routineFaellig = (!$gueltig && !$berechtigungenVeraltet) || $geraetezugangFehlt @@ -1183,8 +1408,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle Lizenzpruefung::pruefeLease( $neueLease, $installationID, - $this->ReadPropertyInteger('Lastspitzenmodus') + $this->lizenzPeakModus() ); + if (!$this->lizenzBerechtigungenPassen( + $neueLease, + $this->lizenzPeakModus() + )) { + throw new InvalidArgumentException( + 'Die Lizenz deckt die konfigurierten Funktionen oder Geraetemengen nicht ab.' + ); + } if ($geraetezugangFehlt) { $this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken); } @@ -1230,7 +1463,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function lizenzPeakModus(): int + { + $modus = $this->ReadPropertyInteger('Lastspitzenmodus'); + if ($modus !== 0) { + return $modus; + } + + return $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv') ? 1 : 0; + } + private function peakShavingLizenziert(): bool + { + return $this->berechtigungLizenziert(Lizenzpruefung::MANAGER_PEAK); + } + + private function berechtigungLizenziert(string $kennung): bool { $lizenzcode = Lizenzpruefung::normalisiereCode( $this->ReadPropertyString('Lizenzcode') @@ -1255,10 +1503,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle return false; } - return Lizenzpruefung::berechtigungsMenge( - $lease, - Lizenzpruefung::MANAGER_PEAK - ) > 0; + return Lizenzpruefung::berechtigungsMenge($lease, $kennung) > 0; } /** @@ -1266,7 +1511,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle */ private function lizenzBerechtigungenPassen(array $lease, int $lastspitzenmodus): bool { - if ($lastspitzenmodus !== 0 + if (($lastspitzenmodus !== 0 || $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) && Lizenzpruefung::berechtigungsMenge( $lease, Lizenzpruefung::MANAGER_PEAK @@ -1274,6 +1519,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle ) { return false; } + if ($this->ReadPropertyBoolean('NetzfahrplanAktiv') + && Lizenzpruefung::berechtigungsMenge( + $lease, + Lizenzpruefung::NETZFAHRPLAN + ) < 1 + ) { + return false; + } try { [$mengen] = $this->verbraucherLizenzbelegung( @@ -1356,7 +1609,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle Lizenzpruefung::pruefeLease( $lease, $this->ReadAttributeString('LizenzInstallationID'), - $this->ReadPropertyInteger('Lastspitzenmodus') + $this->lizenzPeakModus() ); $this->aktiveVerbraucherIDs(); $status = ($lease['development'] ?? false) === true @@ -1498,6 +1751,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle ) { throw new InvalidArgumentException('Umschaltdifferenz muss zwischen 0 und 100 liegen.'); } + if ($this->ReadPropertyFloat('Einspeisegrenze') < 0.0 + || $this->ReadPropertyFloat('Einspeisetoleranz') < 0.0 + ) { + throw new InvalidArgumentException('Einspeisegrenze und Einspeisetoleranz duerfen nicht negativ sein.'); + } + if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) { + $this->regelbareWechselrichter(); + } + if ($this->ReadPropertyBoolean('NetzfahrplanAktiv') + && (!$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv') + || !$this->prognoseTelemetrieKonfiguriert()) + ) { + throw new InvalidArgumentException( + 'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.' + ); + } $this->leseAktiveVerbraucherIDs('VerbraucherZuordnung'); $this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung'); $this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen')); @@ -1537,6 +1806,48 @@ class Manager extends IPSModule implements ManagerSchnittstelle && $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0; } + private function aktuelleNetzfahrplanVorgabe(): ?float + { + if (!$this->ReadPropertyBoolean('NetzfahrplanAktiv')) { + return null; + } + $fahrplan = $this->leseJsonAttribut('Netzfahrplan'); + if (($fahrplan['status'] ?? null) !== 'available' + || !is_array($fahrplan['points'] ?? null) + || !is_string($fahrplan['validUntil'] ?? null) + ) { + return null; + } + $gueltigBis = strtotime($fahrplan['validUntil']); + if ($gueltigBis === false || $gueltigBis <= time()) { + return null; + } + + $jetzt = time(); + $naechsterAbstand = PHP_INT_MAX; + $vorgabe = null; + foreach ($fahrplan['points'] as $punkt) { + if (!is_array($punkt) + || !is_string($punkt['time'] ?? null) + || (!is_int($punkt['gridTargetW'] ?? null) + && !is_float($punkt['gridTargetW'] ?? null)) + ) { + continue; + } + $zeitpunkt = strtotime($punkt['time']); + if ($zeitpunkt === false) { + continue; + } + $abstand = abs($zeitpunkt - $jetzt); + if ($abstand < $naechsterAbstand) { + $naechsterAbstand = $abstand; + $vorgabe = (float) $punkt['gridTargetW']; + } + } + + return $naechsterAbstand <= 1800 ? $vorgabe : null; + } + private function synchronisiereAnlagentopologie(): void { try { @@ -1667,6 +1978,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle 'values' => $werte, ]; $this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token); + if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) { + $this->aktualisiereNetzfahrplan($installationID, $token); + } $this->setzeDiagnosewert('Prognosestatus', 'Verbunden'); } catch (Throwable $fehler) { $meldung = substr($fehler->getMessage(), 0, 500); @@ -1675,6 +1989,73 @@ class Manager extends IPSModule implements ManagerSchnittstelle } } + private function aktualisiereNetzfahrplan(string $installationID, string $token): void + { + $url = sprintf( + self::PROGNOSE_FAHRPLAN_ENDPOINT, + rawurlencode($installationID) + ) . '?' . http_build_query([ + 'exportLimitW' => $this->ReadPropertyFloat('Einspeisegrenze'), + ]); + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Der Netzfahrplan konnte nicht vorbereitet werden.'); + } + + try { + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 10, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + ], + CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2', + ]); + $antwort = curl_exec($curl); + if ($antwort === false) { + throw new RuntimeException('Netzfahrplan nicht erreichbar: ' . curl_error($curl)); + } + if (strlen($antwort) > 524288) { + throw new RuntimeException('Die Antwort des Netzfahrplans ist zu gross.'); + } + $httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE); + } finally { + curl_close($curl); + } + + if ($httpStatus === 401) { + $this->WriteAttributeString('PrognoseInstallationsToken', ''); + } + if ($httpStatus !== 200) { + $meldung = 'Unbekannter Fehler'; + try { + $fehlerdaten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (is_array($fehlerdaten) && is_string($fehlerdaten['error'] ?? null)) { + $meldung = $fehlerdaten['error']; + } + } catch (Throwable $fehler) { + // Der HTTP-Status bleibt die fuehrende Fehlerinformation. + } + throw new RuntimeException( + 'Netzfahrplan HTTP ' . $httpStatus . ': ' . $meldung + ); + } + + $fahrplan = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($fahrplan) + || ($fahrplan['schemaVersion'] ?? null) !== 1 + || !is_array($fahrplan['points'] ?? null) + ) { + throw new RuntimeException('Der Netzfahrplan hat ein ungueltiges Format.'); + } + $this->WriteAttributeString( + 'Netzfahrplan', + json_encode($fahrplan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) + ); + } + /** @param array $nutzlast */ private function uebertragePrognoseTelemetrie( string $installationID, diff --git a/docs/module/Manager/README.md b/docs/module/Manager/README.md index 06d3d6b..f42a0c8 100644 --- a/docs/module/Manager/README.md +++ b/docs/module/Manager/README.md @@ -16,7 +16,10 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `Betriebsart` | String / Anzeige | `Inaktiv`, `PV` oder `Peak`. | | `Netzleistung` | Float / Anzeige | Aktuelle Netzleistung in W; positiv Bezug, negativ Einspeisung. | | `NetzleistungGueltig` | Boolean / Logging | Messquelle vorhanden und aktuell. | -| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Grenze in W. | +| `WirksameLastspitzengrenze` | Float / Logging | Aktuelle feste oder monatliche Bezugsgrenze in W. | +| `WirksameEinspeisegrenze` | Float / Logging | Anlagenweite Einspeisegrenze in W. | +| `Abregelbedarf` | Float / Logging | Aktuell erforderliche PV-Leistungsreduktion in W. | +| `Wechselrichterstatus` | String/JSON / Logging | Verteilte Grenzen und Zustand der PV-Regelung. | | `Verteilbudget` | Float / Logging | Aktuell verfügbares Budget in W. | | `VerbraucherAnzahl` | Integer / Logging | Anzahl zugeordneter Verbraucher. | | `VerbraucherVerfuegbar` | Integer / Logging | Anzahl aktuell verfügbarer Verbraucher. | @@ -47,7 +50,11 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz | `Monatsgrenzen` | String/JSON | Editierbare Liste mit zwölf Monatswerten in W. | | `SollwertSolarladen` | Float | `0` W; gewünschte Netzleistung im Solarladebetrieb. | | `Umschaltdifferenz` | Float | `5` %; Mindestdifferenz der berechneten Sollleistungen vor Umschaltung. | -| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. | +| `EinspeisebegrenzungAktiv` | Boolean | `false`; gemeinsame Exportgrenze am Netzanschlusspunkt aktivieren. | +| `Einspeisegrenze` | Float | Maximale Einspeisung der Gesamtanlage in W; `0` bedeutet Nulleinspeisung. | +| `Einspeisetoleranz` | Float | `100` W; Totband gegen pendelnde Wechselrichtervorgaben. | +| `NetzfahrplanAktiv` | Boolean | `false`; lizenzierten, tarif- und prognosebasierten Netzzielwert verwenden. | +| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ, AC-Grenzen sowie optionalen Istleistungs- und Begrenzungsregistern. | | `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. | | `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. | | `PrognosePVVariableID` | Integer | `0`; PV-Leistung in W fuer die Prognosetelemetrie. | @@ -67,6 +74,33 @@ Die Monatsgrenzen werden in den Grundeinstellungen über eine Schaltfläche ein- und ausgeblendet. Die automatische Verbrauchersuche kann dort erneut ausgeführt werden, ohne andere ungespeicherte Formulareingaben zu verlieren. +## Anlagenweite Einspeisebegrenzung + +Die Einspeisebegrenzung ist Bestandteil von Peak Shaving und arbeitet auf der +Messung am Netzanschlusspunkt. Fuer jeden regelbaren PV- oder Hybridwechselrichter +werden die Istleistungsvariable und eine bedienbare Begrenzungsvariable +konfiguriert. Der Stellwert kann als Prozent der AC-Nennleistung oder als +absolute Leistung in Watt ausgegeben werden. + +Der Manager berechnet eine einzige Grenze fuer die Gesamtanlage. Beim Abregeln +verteilt er sie nach der tatsaechlichen Erzeugung, beim Freigeben nach der +AC-Nennleistung auf alle angebotenen Wechselrichter. Das Totband verhindert +Pendeln am Grenzwert. Beim Abschalten des Managers oder der +Funktion werden zuvor gesetzte Grenzen kontrolliert auf die jeweilige +Nennleistung zurueckgesetzt. Wechselrichter ohne beide Register werden nicht +geregelt; sobald eines der beiden Register gesetzt ist, muessen beide gueltig +sein und das Begrenzungsregister eine IP-Symcon-Aktion besitzen. + +Der intelligente Netzfahrplan verwendet die PV-, Verbrauchs- und +Netzleistungsprognose sowie Tarife und Batteriespeicher. Liegt im +Prognosezeitraum mehr PV-Ertrag als zulaessige Einspeisung vor, wird dieses +Potenzial als flexibler Verbrauch beziehungsweise Speicherladung eingeplant. +Damit werden Verbraucher in ertragreiche Zeitfenster verschoben, bevor der +harte Anlagenregler die Wechselrichter reduziert. Ein fehlender oder +abgelaufener Fahrplan fuehrt automatisch zum konfigurierten +`SollwertSolarladen` zurueck; die harte Einspeisebegrenzung bleibt unabhaengig +davon aktiv. + ## Anlagentopologie und Prognoseexport Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die @@ -137,8 +171,11 @@ wird vom Lizenzserver abgelehnt. | Berechtigung | Freigegebene Funktion | | --- | --- | -| `manager_standard` | Manager-Grundregelung ohne Lastspitzenmodus. | -| `manager_peak` | Manager-Grundregelung einschliesslich konstantem oder monatlichem Lastspitzenmodus. | +| `manager_standard` | Manager-Grundregelung ohne Peak Shaving. | +| `manager_peak` | Bezugs- und Einspeisebegrenzung am Netzanschlusspunkt. | +| `forecast_pv` | PV-Ertragsprognose; interne Modellvarianten bleiben verborgen. | +| `forecast_load` | Verbrauchsprognose; interne Modellvarianten bleiben verborgen. | +| `grid_schedule` | Intelligenter Netzfahrplan inklusive PV- und Verbrauchsprognose sowie Vermeidung von Abregelung. | Wird mit `manager_standard` ein Lastspitzenmodus aktiviert, bleibt der Manager mit dem Hinweis `Peak Shaving ist nicht lizenziert` gesperrt. `manager_peak` diff --git a/libs/EinspeiseRegler.php b/libs/EinspeiseRegler.php new file mode 100644 index 0000000..d4c7577 --- /dev/null +++ b/libs/EinspeiseRegler.php @@ -0,0 +1,127 @@ + $wechselrichter + * @param array $letzteGrenzen + * @return array{ + * Netzabweichung_W: float, + * ZielGesamtleistung_W: float, + * Abregelbedarf_W: float, + * Grenzen_W: array + * } + */ + public static function verteile( + float $netzleistung, + float $einspeisegrenze, + float $toleranz, + array $wechselrichter, + array $letzteGrenzen = [] + ): array { + if ($einspeisegrenze < 0.0) { + throw new InvalidArgumentException('Einspeisegrenze darf nicht negativ sein.'); + } + if ($toleranz < 0.0) { + throw new InvalidArgumentException('Einspeisetoleranz darf nicht negativ sein.'); + } + if ($wechselrichter === []) { + throw new InvalidArgumentException('Mindestens ein regelbarer Wechselrichter ist erforderlich.'); + } + + $nennleistungGesamt = 0.0; + $istleistungGesamt = 0.0; + $bisherigeGrenzeGesamt = 0.0; + $normalisiert = []; + $kennungen = []; + + foreach ($wechselrichter as $eintrag) { + $id = $eintrag['ID'] ?? null; + $nennleistung = $eintrag['Nennleistung_W'] ?? null; + $istleistung = $eintrag['Istleistung_W'] ?? null; + if (!is_string($id) || trim($id) === '' || isset($kennungen[$id])) { + throw new InvalidArgumentException('Wechselrichter-ID fehlt oder ist mehrfach vorhanden.'); + } + if (!is_numeric($nennleistung) || (float) $nennleistung <= 0.0) { + throw new InvalidArgumentException('Wechselrichter-Nennleistung muss groesser als 0 sein.'); + } + if (!is_numeric($istleistung) || (float) $istleistung < 0.0) { + throw new InvalidArgumentException('Wechselrichter-Istleistung darf nicht negativ sein.'); + } + + $nennleistung = (float) $nennleistung; + $istleistung = min((float) $istleistung, $nennleistung); + $bisherigeGrenze = $letzteGrenzen[$id] ?? $nennleistung; + if (!is_numeric($bisherigeGrenze)) { + throw new InvalidArgumentException('Gespeicherte Wechselrichtergrenze ist ungueltig.'); + } + $bisherigeGrenze = max(0.0, min((float) $bisherigeGrenze, $nennleistung)); + $normalisiert[] = [ + 'ID' => $id, + 'Nennleistung_W' => $nennleistung, + 'Istleistung_W' => $istleistung, + ]; + $kennungen[$id] = true; + $nennleistungGesamt += $nennleistung; + $istleistungGesamt += $istleistung; + $bisherigeGrenzeGesamt += $bisherigeGrenze; + } + + $netzabweichung = $netzleistung + $einspeisegrenze; + $zielGesamt = $bisherigeGrenzeGesamt; + $nachIstleistungVerteilen = false; + if ($netzabweichung < -$toleranz) { + $nachIstleistungVerteilen = $istleistungGesamt > 0.0; + $wirksameAbweichung = $netzabweichung + $toleranz; + $zielGesamt = min($bisherigeGrenzeGesamt, $istleistungGesamt) + $wirksameAbweichung; + } elseif ($netzabweichung > $toleranz) { + $zielGesamt = $bisherigeGrenzeGesamt + $netzabweichung - $toleranz; + } + $zielGesamt = max(0.0, min($zielGesamt, $nennleistungGesamt)); + + $grenzen = []; + foreach ($normalisiert as $eintrag) { + $anteil = $nachIstleistungVerteilen + ? $eintrag['Istleistung_W'] / $istleistungGesamt + : $eintrag['Nennleistung_W'] / $nennleistungGesamt; + $grenzen[$eintrag['ID']] = round( + min($eintrag['Nennleistung_W'], $zielGesamt * $anteil), + 3 + ); + } + + return [ + 'Netzabweichung_W' => $netzabweichung, + 'ZielGesamtleistung_W' => round($zielGesamt, 3), + 'Abregelbedarf_W' => round(max(0.0, $istleistungGesamt - $zielGesamt), 3), + 'Grenzen_W' => $grenzen, + ]; + } + + public static function stellwert(float $grenzeW, float $nennleistungW, string $art): float + { + if ($nennleistungW <= 0.0 || $grenzeW < 0.0) { + throw new InvalidArgumentException('Leistungsgrenzen sind ungueltig.'); + } + $grenzeW = min($grenzeW, $nennleistungW); + if ($art === 'percent') { + return round($grenzeW / $nennleistungW * 100.0, 2); + } + if ($art === 'watt') { + return round($grenzeW, 1); + } + + throw new InvalidArgumentException('Begrenzungsart muss percent oder watt sein.'); + } +} diff --git a/libs/Lizenzpruefung.php b/libs/Lizenzpruefung.php index 553633f..6c650ba 100644 --- a/libs/Lizenzpruefung.php +++ b/libs/Lizenzpruefung.php @@ -12,6 +12,9 @@ final class Lizenzpruefung { public const MANAGER_STANDARD = 'manager_standard'; public const MANAGER_PEAK = 'manager_peak'; + public const PROGNOSE_PV = 'forecast_pv'; + public const PROGNOSE_VERBRAUCH = 'forecast_load'; + public const NETZFAHRPLAN = 'grid_schedule'; public static function normalisiereCode(string $code): string { diff --git a/tests/EinspeiseReglerTest.php b/tests/EinspeiseReglerTest.php new file mode 100644 index 0000000..33bf2df --- /dev/null +++ b/tests/EinspeiseReglerTest.php @@ -0,0 +1,89 @@ + 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000], + ['ID' => 'wr-2', 'Nennleistung_W' => 5000, 'Istleistung_W' => 5000], + ] + ); + + self::assertSame(10100.0, $ergebnis['ZielGesamtleistung_W']); + self::assertSame(4900.0, $ergebnis['Abregelbedarf_W']); + self::assertSame(6733.333, $ergebnis['Grenzen_W']['wr-1']); + self::assertSame(3366.667, $ergebnis['Grenzen_W']['wr-2']); + } + + public function testAbregelungFolgtDerTatsaechlichenErzeugung(): void + { + $ergebnis = EinspeiseRegler::verteile( + -10000.0, + 5000.0, + 0.0, + [ + ['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 10000], + ['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 0], + ] + ); + + self::assertSame(5000.0, $ergebnis['Grenzen_W']['wr-1']); + self::assertSame(0.0, $ergebnis['Grenzen_W']['wr-2']); + } + + public function testFreierNetzspielraumHebtEineBestehendeBegrenzungAn(): void + { + $ergebnis = EinspeiseRegler::verteile( + -6000.0, + 10000.0, + 100.0, + [ + ['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 4000], + ['ID' => 'wr-2', 'Nennleistung_W' => 10000, 'Istleistung_W' => 2000], + ], + ['wr-1' => 5000, 'wr-2' => 5000] + ); + + self::assertSame(13900.0, $ergebnis['ZielGesamtleistung_W']); + self::assertSame(6950.0, $ergebnis['Grenzen_W']['wr-1']); + self::assertSame(6950.0, $ergebnis['Grenzen_W']['wr-2']); + } + + public function testToleranzVerhindertPendelnAmGrenzwert(): void + { + $ergebnis = EinspeiseRegler::verteile( + -10050.0, + 10000.0, + 100.0, + [['ID' => 'wr-1', 'Nennleistung_W' => 10000, 'Istleistung_W' => 9000]], + ['wr-1' => 8000] + ); + + self::assertSame(8000.0, $ergebnis['ZielGesamtleistung_W']); + } + + public function testStellwerteWerdenInProzentOderWattAusgegeben(): void + { + self::assertSame(62.5, EinspeiseRegler::stellwert(5000.0, 8000.0, 'percent')); + self::assertSame(5000.0, EinspeiseRegler::stellwert(5000.0, 8000.0, 'watt')); + } + + public function testUngueltigeBegrenzungsartWirdAbgelehnt(): void + { + $this->expectException(InvalidArgumentException::class); + EinspeiseRegler::stellwert(5000.0, 8000.0, 'volt'); + } +} diff --git a/tests/ManagerModulstrukturTest.php b/tests/ManagerModulstrukturTest.php index 20764a0..e2e7cc0 100644 --- a/tests/ManagerModulstrukturTest.php +++ b/tests/ManagerModulstrukturTest.php @@ -48,6 +48,13 @@ final class ManagerModulstrukturTest extends TestCase 'VerbraucherTimeout', 'MesswertMaxAlter', 'Umschaltdifferenz', + 'EinspeisebegrenzungAktiv', + 'Einspeisegrenze', + 'Einspeisetoleranz', + 'NetzfahrplanAktiv', + 'IstleistungVariableID', + 'BegrenzungVariableID', + 'Begrenzungsart', 'DiagnosevariablenAnzeigen', 'LoggingEin', 'Lizenzcode', @@ -230,6 +237,24 @@ final class ManagerModulstrukturTest extends TestCase ); } + public function testManagerRegeltDieAnlagenweiteEinspeisegrenzeUndDenNetzfahrplan(): void + { + $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); + + self::assertFileExists(__DIR__ . '/../libs/EinspeiseRegler.php'); + self::assertStringContainsString('EinspeiseRegler::verteile(', $inhalt); + self::assertStringContainsString('EinspeiseRegler::stellwert(', $inhalt); + self::assertStringContainsString("RegisterAttributeString('LetztePvGrenzen'", $inhalt); + self::assertStringContainsString("RegisterAttributeString('Netzfahrplan'", $inhalt); + self::assertStringContainsString( + 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule', + $inhalt + ); + self::assertStringContainsString('Lizenzpruefung::NETZFAHRPLAN', $inhalt); + self::assertStringContainsString('aktuelleNetzfahrplanVorgabe()', $inhalt); + self::assertStringContainsString('gibEinspeisebegrenzungFrei()', $inhalt); + } + public function testManagerVerwendetDieNeuenZeitdefaults(): void { $inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php'); diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index ea739a2..a0af911 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -47,6 +47,7 @@ return [ 'file' => __DIR__ . '/modules/Manager.php', 'paths' => [ 'Manager/*', + 'libs/EinspeiseRegler.php', 'libs/Lizenzpruefung.php', 'libs/ManagerRegler.php', 'libs/ManagerSchnittstelle.php',