This commit is contained in:
+21
-3
@@ -539,9 +539,27 @@
|
||||
"caption": "Prognose / Forecast",
|
||||
"items": [
|
||||
{
|
||||
"type": "ValidationTextBox",
|
||||
"name": "PrognoseAnschluss",
|
||||
"caption": "Konfiguration als JSON"
|
||||
"type": "SelectVariable",
|
||||
"name": "PrognosePVVariableID",
|
||||
"caption": "PV-Leistung"
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "PrognoseHausverbrauchVariableID",
|
||||
"caption": "Hausverbrauch"
|
||||
},
|
||||
{
|
||||
"type": "SelectVariable",
|
||||
"name": "PrognoseSOCVariableID",
|
||||
"caption": "Batterie-Ladezustand"
|
||||
},
|
||||
{
|
||||
"type": "NumberSpinner",
|
||||
"name": "PrognoseSendeintervall",
|
||||
"caption": "Sendeintervall",
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"suffix": " Sekunden"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+165
-1
@@ -22,6 +22,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
private const STATUS_LIZENZ_UNGUELTIG = 203;
|
||||
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 LIZENZ_RETRY_SEKUNDEN = 3600;
|
||||
private const VM_UPDATE = 10603;
|
||||
private const VERBRAUCHER_LIZENZEN = [
|
||||
@@ -97,6 +98,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
|
||||
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
|
||||
$this->RegisterPropertyString('AnlagenBatterien', '[]');
|
||||
$this->RegisterPropertyInteger('PrognosePVVariableID', 0);
|
||||
$this->RegisterPropertyInteger('PrognoseHausverbrauchVariableID', 0);
|
||||
$this->RegisterPropertyInteger('PrognoseSOCVariableID', 0);
|
||||
$this->RegisterPropertyInteger('PrognoseSendeintervall', 60);
|
||||
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
|
||||
$this->RegisterPropertyString('SDLAnschluss', '{}');
|
||||
$this->RegisterPropertyString('Lizenzcode', '');
|
||||
@@ -130,6 +135,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);"
|
||||
);
|
||||
$this->RegisterTimer(
|
||||
'PrognoseSenden',
|
||||
0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseSenden', true);"
|
||||
);
|
||||
}
|
||||
|
||||
public function ApplyChanges(): void
|
||||
@@ -146,6 +156,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->SetTimerInterval('Regelzyklus', 0);
|
||||
$this->SetTimerInterval('VorgabenErneuern', 0);
|
||||
$this->SetTimerInterval('KeepAlive', 0);
|
||||
$this->SetTimerInterval('PrognoseSenden', 0);
|
||||
$this->protokolliere('Konfiguration', $fehler->getMessage());
|
||||
return;
|
||||
}
|
||||
@@ -154,9 +165,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->SetTimerInterval('Regelzyklus', 0);
|
||||
$this->SetTimerInterval('VorgabenErneuern', 0);
|
||||
$this->SetTimerInterval('KeepAlive', $this->ReadPropertyInteger('KeepAlive') * 1000);
|
||||
$this->SetTimerInterval('PrognoseSenden', 0);
|
||||
$this->setzeDiagnosewert('Monatsgrenzen', $this->ReadPropertyString('Monatsgrenzen'));
|
||||
$this->aktualisiereAnschlussstatus();
|
||||
$geraetezugangFehlt = $this->hatAnlagentopologie()
|
||||
$geraetezugangFehlt = ($this->hatAnlagentopologie() || $this->prognoseTelemetrieKonfiguriert())
|
||||
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
|
||||
if (!$this->aktualisiereLizenzfreigabe(
|
||||
$this->ReadPropertyString('Lizenzcode'),
|
||||
@@ -176,6 +188,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
}
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
$this->synchronisiereAnlagentopologie();
|
||||
if ($this->prognoseTelemetrieKonfiguriert()) {
|
||||
$this->SetTimerInterval(
|
||||
'PrognoseSenden',
|
||||
$this->ReadPropertyInteger('PrognoseSendeintervall') * 1000
|
||||
);
|
||||
$this->sendePrognoseTelemetrie();
|
||||
}
|
||||
}
|
||||
|
||||
public function GetConfigurationForm(): string
|
||||
@@ -295,6 +314,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->regeln((bool) $wert);
|
||||
return;
|
||||
|
||||
case 'PrognoseSenden':
|
||||
$this->sendePrognoseTelemetrie();
|
||||
return;
|
||||
|
||||
case 'FormLizenzPruefen':
|
||||
if (!is_string($wert)) {
|
||||
throw new InvalidArgumentException('Der Lizenzcode muss als Text uebergeben werden.');
|
||||
@@ -1447,6 +1470,29 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
throw new InvalidArgumentException($property . ' muss groesser als 0 sein.');
|
||||
}
|
||||
}
|
||||
$intervall = $this->ReadPropertyInteger('PrognoseSendeintervall');
|
||||
if ($intervall < 60 || $intervall > 3600) {
|
||||
throw new InvalidArgumentException('PrognoseSendeintervall muss zwischen 60 und 3600 Sekunden liegen.');
|
||||
}
|
||||
$telemetrieIDs = [
|
||||
$this->ReadPropertyInteger('PrognosePVVariableID'),
|
||||
$this->ReadPropertyInteger('PrognoseHausverbrauchVariableID'),
|
||||
$this->ReadPropertyInteger('PrognoseSOCVariableID'),
|
||||
];
|
||||
$konfigurierteIDs = array_values(array_filter($telemetrieIDs, static fn (int $id): bool => $id > 0));
|
||||
if ($konfigurierteIDs !== [] && count($konfigurierteIDs) !== count($telemetrieIDs)) {
|
||||
throw new InvalidArgumentException('Fuer die Prognose muessen PV, Hausverbrauch und SOC gemeinsam konfiguriert sein.');
|
||||
}
|
||||
foreach ($konfigurierteIDs as $variableID) {
|
||||
if (!IPS_VariableExists($variableID)) {
|
||||
throw new InvalidArgumentException('Eine konfigurierte Prognosevariable existiert nicht.');
|
||||
}
|
||||
}
|
||||
if ($konfigurierteIDs !== []
|
||||
&& !IPS_VariableExists($this->ReadPropertyInteger('NetzleistungVariableID'))
|
||||
) {
|
||||
throw new InvalidArgumentException('Die Netzleistungsvariable fuer die Prognose existiert nicht.');
|
||||
}
|
||||
if ($this->ReadPropertyFloat('Umschaltdifferenz') < 0
|
||||
|| $this->ReadPropertyFloat('Umschaltdifferenz') > 100
|
||||
) {
|
||||
@@ -1484,6 +1530,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
|| $topologie['batteries'] !== [];
|
||||
}
|
||||
|
||||
private function prognoseTelemetrieKonfiguriert(): bool
|
||||
{
|
||||
return $this->ReadPropertyInteger('PrognosePVVariableID') > 0
|
||||
&& $this->ReadPropertyInteger('PrognoseHausverbrauchVariableID') > 0
|
||||
&& $this->ReadPropertyInteger('PrognoseSOCVariableID') > 0;
|
||||
}
|
||||
|
||||
private function synchronisiereAnlagentopologie(): void
|
||||
{
|
||||
try {
|
||||
@@ -1572,6 +1625,117 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
}
|
||||
}
|
||||
|
||||
private function sendePrognoseTelemetrie(): void
|
||||
{
|
||||
if (!$this->prognoseTelemetrieKonfiguriert()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
|
||||
}
|
||||
|
||||
$werte = [
|
||||
'PV' => GetValue($this->ReadPropertyInteger('PrognosePVVariableID')),
|
||||
'Hausverbrauch' => GetValue($this->ReadPropertyInteger('PrognoseHausverbrauchVariableID')),
|
||||
'Netzleistung' => GetValue($this->ReadPropertyInteger('NetzleistungVariableID')),
|
||||
'SOC' => GetValue($this->ReadPropertyInteger('PrognoseSOCVariableID')),
|
||||
];
|
||||
foreach ($werte as $name => $wert) {
|
||||
if (!is_int($wert) && !is_float($wert)) {
|
||||
throw new RuntimeException('Der Prognosewert ' . $name . ' ist keine Zahl.');
|
||||
}
|
||||
$werte[$name] = (float) $wert;
|
||||
if (!is_finite($werte[$name])) {
|
||||
throw new RuntimeException('Der Prognosewert ' . $name . ' ist nicht endlich.');
|
||||
}
|
||||
}
|
||||
$werte['Netzleistung'] *= $this->ReadPropertyFloat('Netzleistungsfaktor');
|
||||
if ($werte['PV'] < 0.0 || $werte['Hausverbrauch'] < 0.0
|
||||
|| $werte['SOC'] < 0.0 || $werte['SOC'] > 100.0
|
||||
) {
|
||||
throw new RuntimeException('Mindestens ein Prognosewert liegt ausserhalb des gueltigen Bereichs.');
|
||||
}
|
||||
|
||||
$installationID = $this->ReadAttributeString('LizenzInstallationID');
|
||||
$nutzlast = [
|
||||
'version' => '1.0',
|
||||
'installationId' => $installationID,
|
||||
'capturedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'values' => $werte,
|
||||
];
|
||||
$this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token);
|
||||
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
||||
} catch (Throwable $fehler) {
|
||||
$meldung = substr($fehler->getMessage(), 0, 500);
|
||||
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
|
||||
$this->protokolliere('Prognosetelemetrie', $meldung);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $nutzlast */
|
||||
private function uebertragePrognoseTelemetrie(
|
||||
string $installationID,
|
||||
array $nutzlast,
|
||||
string $token
|
||||
): void {
|
||||
$url = sprintf(self::PROGNOSE_TELEMETRIE_ENDPOINT, rawurlencode($installationID));
|
||||
$json = json_encode(
|
||||
$nutzlast,
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
||||
);
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Die Prognosetelemetrie konnte nicht vorbereitet werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
if ($antwort === false) {
|
||||
throw new RuntimeException('Prognosedienst nicht erreichbar: ' . curl_error($curl));
|
||||
}
|
||||
if (strlen($antwort) > 65536) {
|
||||
throw new RuntimeException('Die Antwort der Prognosetelemetrie ist zu gross.');
|
||||
}
|
||||
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
} finally {
|
||||
curl_close($curl);
|
||||
}
|
||||
|
||||
if ($httpStatus === 401 || $httpStatus === 403) {
|
||||
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
||||
}
|
||||
if ($httpStatus !== 200) {
|
||||
$meldung = 'Unbekannter Fehler';
|
||||
try {
|
||||
$daten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (is_array($daten) && is_string($daten['error'] ?? null)) {
|
||||
$meldung = $daten['error'];
|
||||
}
|
||||
} catch (Throwable $fehler) {
|
||||
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
||||
}
|
||||
throw new RuntimeException(
|
||||
'Prognosetelemetrie HTTP ' . $httpStatus . ': ' . $meldung
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int, float> */
|
||||
private function dekodiereMonatsgrenzen(string $json): array
|
||||
{
|
||||
|
||||
@@ -50,7 +50,11 @@ Ohne angelegte Diagnosevariablen existieren nur `Aktiv`, `Betriebsart` und `Netz
|
||||
| `AnlagenWechselrichter` | String/JSON | `[]`; Wechselrichter mit Typ und AC-Grenzen. |
|
||||
| `AnlagenPVFlaechen` | String/JSON | `[]`; PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichter-ID. |
|
||||
| `AnlagenBatterien` | String/JSON | `[]`; Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und Wechselrichter-ID. |
|
||||
| `PrognoseAnschluss` | String/JSON | Optionaler Forecast-Anschluss. |
|
||||
| `PrognosePVVariableID` | Integer | `0`; PV-Leistung in W fuer die Prognosetelemetrie. |
|
||||
| `PrognoseHausverbrauchVariableID` | Integer | `0`; Hausverbrauch in W fuer die Prognosetelemetrie. |
|
||||
| `PrognoseSOCVariableID` | Integer | `0`; Batterie-Ladezustand in Prozent. |
|
||||
| `PrognoseSendeintervall` | Integer | `60` s; zulaessig sind 60 bis 3600 Sekunden. |
|
||||
| `PrognoseAnschluss` | String/JSON | Verdeckte Altproperty fuer bestehende Konfigurationen. |
|
||||
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
|
||||
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
|
||||
| `StoermeldeAnschluss` | String/JSON | Optionaler Anschluss zur Störüberwachung. |
|
||||
@@ -87,6 +91,15 @@ internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
|
||||
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
|
||||
EMS-Regelung aber nicht.
|
||||
|
||||
Sind PV-Leistung, Hausverbrauch und Batterie-SOC gemeinsam konfiguriert, sendet
|
||||
der Manager diese Werte zusammen mit der faktorbereinigten Netzleistung und
|
||||
einem UTC-Zeitstempel im eingestellten Intervall. Das Minimum von 60 Sekunden
|
||||
passt zum Rate-Limit des Lizenzportals. Die Uebertragung nutzt denselben
|
||||
widerrufbaren Installationszugang wie die Topologie; Lizenzcode und Geraet Token
|
||||
werden weder als Telemetriefelder noch im Debug-Log ausgegeben. Bei HTTP 401
|
||||
oder 403 verwirft der Manager den Geraetezugang und fordert ihn bei der naechsten
|
||||
Lizenzpruefung neu an.
|
||||
|
||||
Die Entscheidung und ihre Alternativen sind in
|
||||
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ Die Demo erzeugt idempotent eine vollständige, spielbare EMS-Anlage in IP-Symco
|
||||
- einen bidirektionalen Batteriespeicher (10 kWh, +/-3,5 kW),
|
||||
- eine technische Anlagentopologie mit Hybridwechselrichter, PV-Fläche und
|
||||
Batteriespeicher für die Prognoseanbindung,
|
||||
- Prognosetelemetrie aus PV, Hausverbrauch, Netzleistung und Batterie-SOC im
|
||||
60-Sekunden-Intervall,
|
||||
- simulierte PV-Erzeugung, variable Bewölkung, Tageslastgang, Zusatzlast,
|
||||
Außentemperatur, Wärmeverluste, Netzleistung und Speicherzustände,
|
||||
- native Symcon-Instanz `Energy Distribution` für acht Energieflussknoten,
|
||||
@@ -45,7 +47,9 @@ Die Demo übernimmt die einstellbare PV-Spitzenleistung als AC- und
|
||||
DC-Anlagenleistung. Für die feste Beispieltopologie gelten 30 Grad Neigung und
|
||||
Südausrichtung. Der 10-kWh-Speicher nutzt den gemeinsamen Hybridwechselrichter;
|
||||
seine dynamischen Lade- und Entladegrenzen werden beim Bootstrap in die
|
||||
technischen Stammdaten übernommen.
|
||||
technischen Stammdaten übernommen. Bei aktiver Manager-Lizenz uebertraegt der
|
||||
Manager zusaetzlich PV-Leistung, Hausverbrauch, Netzleistung und Batterie-SOC
|
||||
alle 60 Sekunden an den Enelix-Prognosedienst.
|
||||
|
||||
## Bedienung
|
||||
|
||||
|
||||
@@ -313,6 +313,10 @@ function enelixDemoInstall(): array
|
||||
$managerProperties = [
|
||||
'NetzleistungVariableID' => $grid,
|
||||
'Netzleistungsfaktor' => 1.0,
|
||||
'PrognosePVVariableID' => $pvDisplay,
|
||||
'PrognoseHausverbrauchVariableID' => $houseDisplay,
|
||||
'PrognoseSOCVariableID' => $batterySoc,
|
||||
'PrognoseSendeintervall' => 60,
|
||||
'MesswertMaxAlter' => 10,
|
||||
'AutomatischeSuche' => false,
|
||||
'SuchbereichID' => $root,
|
||||
|
||||
@@ -119,6 +119,10 @@ final class DemoanlageBootstrapTest extends TestCase
|
||||
self::assertStringContainsString("'AnlagenWechselrichter'", $source);
|
||||
self::assertStringContainsString("'AnlagenPVFlaechen'", $source);
|
||||
self::assertStringContainsString("'AnlagenBatterien'", $source);
|
||||
self::assertStringContainsString("'PrognosePVVariableID' => \$pvDisplay", $source);
|
||||
self::assertStringContainsString("'PrognoseHausverbrauchVariableID' => \$houseDisplay", $source);
|
||||
self::assertStringContainsString("'PrognoseSOCVariableID' => \$batterySoc", $source);
|
||||
self::assertStringContainsString("'PrognoseSendeintervall' => 60", $source);
|
||||
self::assertStringContainsString("'demo-hybrid-1'", $source);
|
||||
self::assertStringContainsString("'demo-pv-1'", $source);
|
||||
self::assertStringContainsString("'demo-battery-1'", $source);
|
||||
|
||||
@@ -59,6 +59,10 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
'AnlagenWechselrichter',
|
||||
'AnlagenPVFlaechen',
|
||||
'AnlagenBatterien',
|
||||
'PrognosePVVariableID',
|
||||
'PrognoseHausverbrauchVariableID',
|
||||
'PrognoseSOCVariableID',
|
||||
'PrognoseSendeintervall',
|
||||
] as $property) {
|
||||
self::assertStringContainsString($property, $json);
|
||||
}
|
||||
@@ -190,7 +194,7 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
|
||||
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
|
||||
self::assertStringContainsString(
|
||||
"\$geraetezugangFehlt = \$this->hatAnlagentopologie()",
|
||||
"\$geraetezugangFehlt = (\$this->hatAnlagentopologie() || \$this->prognoseTelemetrieKonfiguriert())",
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
@@ -198,6 +202,15 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
|
||||
self::assertStringContainsString(
|
||||
'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry',
|
||||
$inhalt
|
||||
);
|
||||
self::assertStringContainsString("'PrognoseSenden',", $inhalt);
|
||||
self::assertStringContainsString("'capturedAt' => gmdate(", $inhalt);
|
||||
foreach (["'PV'", "'Hausverbrauch'", "'Netzleistung'", "'SOC'"] as $messwert) {
|
||||
self::assertStringContainsString($messwert, $inhalt);
|
||||
}
|
||||
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
|
||||
self::assertStringNotContainsString(
|
||||
"protokolliere('Prognosesynchronisation', \$token)",
|
||||
|
||||
Reference in New Issue
Block a user