2 Commits
Author SHA1 Message Date
dh 1e6481330a feat: Anlagentopologie mit Prognoseportal synchronisieren
Tests / test (push) Successful in 45s
2026-09-23 04:36:59 +00:00
dh dbd728dd03 feat: Anlagentopologie im Manager abbilden 2026-09-22 17:11:12 +00:00
9 changed files with 1200 additions and 0 deletions
+276
View File
@@ -206,6 +206,282 @@
}
]
},
{
"type": "ExpansionPanel",
"caption": "Anlagentopologie",
"expanded": true,
"items": [
{
"type": "List",
"name": "AnlagenWechselrichter",
"caption": "Wechselrichter",
"rowCount": 4,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "130px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "Wechselrichter",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Typ",
"name": "Typ",
"width": "130px",
"add": "pv",
"edit": {
"type": "Select",
"options": [
{"caption": "PV", "value": "pv"},
{"caption": "Batterie", "value": "battery"},
{"caption": "Hybrid", "value": "hybrid"}
]
}
},
{
"caption": "AC Nennleistung",
"name": "ACNennleistung_kW",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Einspeisegrenze",
"name": "ACEinspeisegrenze_kW",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Bezugsgrenze",
"name": "ACBezugsgrenze_kW",
"width": "140px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
}
]
},
{
"type": "List",
"name": "AnlagenPVFlaechen",
"caption": "PV-Flaechen",
"rowCount": 6,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "120px",
"add": "pv-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "PV-Flaeche",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "DC-Leistung",
"name": "DCLeistung_kWp",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWp",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Neigung",
"name": "Neigung_Grad",
"width": "110px",
"add": 30.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Grad",
"minimum": 0.0,
"maximum": 90.0,
"digits": 1
}
},
{
"caption": "Azimut",
"name": "Azimut_Grad",
"width": "110px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Grad",
"minimum": -180.0,
"maximum": 180.0,
"digits": 1
}
},
{
"caption": "Wechselrichter-ID",
"name": "WechselrichterID",
"width": "150px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "MPPT",
"name": "MPPT",
"width": "90px",
"add": "",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Module",
"name": "Modulanzahl",
"width": "90px",
"add": 0,
"edit": {
"type": "NumberSpinner",
"minimum": 0,
"digits": 0
}
},
{
"caption": "Modulleistung",
"name": "Modulleistung_Wp",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " Wp",
"minimum": 0.0,
"digits": 1
}
}
]
},
{
"type": "List",
"name": "AnlagenBatterien",
"caption": "Batteriespeicher",
"rowCount": 4,
"add": true,
"delete": true,
"sortable": true,
"columns": [
{
"caption": "ID",
"name": "ID",
"width": "120px",
"add": "bat-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Name",
"name": "Name",
"width": "auto",
"add": "Batteriespeicher",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Nennkapazitaet",
"name": "Nennkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Nutzkapazitaet",
"name": "Nutzkapazitaet_kWh",
"width": "150px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kWh",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Laden",
"name": "MaxLadeleistung_kW",
"width": "130px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Max. Entladen",
"name": "MaxEntladeleistung_kW",
"width": "140px",
"add": 0.0,
"edit": {
"type": "NumberSpinner",
"suffix": " kW",
"minimum": 0.0,
"digits": 3
}
},
{
"caption": "Wechselrichter-ID",
"name": "WechselrichterID",
"width": "150px",
"add": "wr-1",
"edit": {"type": "ValidationTextBox"}
},
{
"caption": "Kopplung",
"name": "Kopplung",
"width": "130px",
"add": "ac",
"edit": {
"type": "Select",
"options": [
{"caption": "AC", "value": "ac"},
{"caption": "DC", "value": "dc"},
{"caption": "Hybrid", "value": "hybrid"}
]
}
}
]
}
]
},
{
"type": "ExpansionPanel",
"caption": "Erweiterte Einstellungen",
+144
View File
@@ -6,7 +6,9 @@ require_once __DIR__ . '/../libs/ManagerSchnittstelle.php';
require_once __DIR__ . '/../libs/Nachrichtenvertrag.php';
require_once __DIR__ . '/../libs/ManagerRegler.php';
require_once __DIR__ . '/../libs/Lizenzpruefung.php';
require_once __DIR__ . '/../libs/Anlagentopologie.php';
use Belevo\EnelixEMS\Anlagentopologie;
use Belevo\EnelixEMS\Lizenzpruefung;
use Belevo\EnelixEMS\ManagerRegler;
use Belevo\EnelixEMS\ManagerSchnittstelle;
@@ -19,6 +21,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
private const STATUS_KONFIGURATION_UNGUELTIG = 202;
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 LIZENZ_RETRY_SEKUNDEN = 3600;
private const VM_UPDATE = 10603;
private const VERBRAUCHER_LIZENZEN = [
@@ -91,6 +94,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterPropertyString('Monatsgrenzen', $this->standardMonatsgrenzen());
$this->RegisterPropertyFloat('SollwertSolarladen', 0.0);
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
$this->RegisterPropertyString('AnlagenBatterien', '[]');
$this->RegisterPropertyString('PrognoseAnschluss', '{}');
$this->RegisterPropertyString('SDLAnschluss', '{}');
$this->RegisterPropertyString('Lizenzcode', '');
@@ -114,6 +120,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->RegisterAttributeInteger('LizenzNaechsterVersuch', 0);
$this->RegisterAttributeInteger('LizenzBerechtigungenNaechsterVersuch', 0);
$this->RegisterAttributeString('LizenzLetzterFehler', '');
$this->RegisterAttributeString('PrognoseInstallationsToken', '');
// Die alten Timer bleiben deaktiviert registriert, damit bestehende Instanzen sauber migrieren.
$this->RegisterTimer('Regelzyklus', 0, '');
@@ -163,6 +170,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return;
}
$this->SetStatus(self::STATUS_AKTIV);
$this->synchronisiereAnlagentopologie();
}
public function GetConfigurationForm(): string
@@ -253,6 +261,14 @@ class Manager extends IPSModule implements ManagerSchnittstelle
return json_encode($formular, JSON_THROW_ON_ERROR);
}
public function AnlagentopologieExportieren(): string
{
return json_encode(
$this->leseAnlagentopologie(),
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
}
public function MessageSink($zeitstempel, $senderID, $nachricht, $daten): void
{
if ((int) $senderID === $this->ReadPropertyInteger('NetzleistungVariableID')
@@ -1065,6 +1081,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$installationID = $this->initialisiereLizenzInstallationID();
$codeHash = hash('sha256', $normalisiert);
$gespeicherterHash = $this->ReadAttributeString('LizenzcodeHash');
if ($gespeicherterHash !== '' && !hash_equals($gespeicherterHash, $codeHash)) {
$this->WriteAttributeString('PrognoseInstallationsToken', '');
}
$lease = $this->leseLizenzLease();
$codePasst = $gespeicherterHash !== '' && hash_equals($gespeicherterHash, $codeHash);
$lastspitzenmodus = $this->ReadPropertyInteger('Lastspitzenmodus');
@@ -1080,7 +1099,10 @@ class Manager extends IPSModule implements ManagerSchnittstelle
);
$berechtigungenVeraltet = $basisGueltig
&& !$this->lizenzBerechtigungenPassen($lease, $lastspitzenmodus);
$geraetezugangFehlt = $this->hatAnlagentopologie()
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '';
$routineFaellig = (!$gueltig && !$berechtigungenVeraltet)
|| $geraetezugangFehlt
|| Lizenzpruefung::erneuerungFaellig($lease);
$jetzt = time();
@@ -1122,11 +1144,22 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
try {
$neueLease = $this->rufeLizenzserverAuf($normalisiert, $installationID);
$geraeteToken = $neueLease['deviceToken'] ?? null;
unset($neueLease['deviceToken']);
if ($geraetezugangFehlt
&& (!is_string($geraeteToken)
|| preg_match('/^[A-Za-z0-9_-]{43,128}$/', $geraeteToken) !== 1)
) {
throw new RuntimeException('Der Lizenzserver lieferte keinen gueltigen Geraetezugang.');
}
Lizenzpruefung::pruefeLease(
$neueLease,
$installationID,
$this->ReadPropertyInteger('Lastspitzenmodus')
);
if ($geraetezugangFehlt) {
$this->WriteAttributeString('PrognoseInstallationsToken', $geraeteToken);
}
$this->WriteAttributeString(
'LizenzLease',
json_encode($neueLease, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
@@ -1321,6 +1354,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$nutzlast = json_encode([
'code' => $lizenzcode,
'installationId' => $installationID,
'issueDeviceToken' => $this->hatAnlagentopologie()
&& $this->ReadAttributeString('PrognoseInstallationsToken') === '',
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$curl = curl_init(self::LIZENZ_ENDPOINT);
if ($curl === false) {
@@ -1415,6 +1450,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
$this->leseAnlagentopologie();
foreach (['PrognoseAnschluss', 'SDLAnschluss', 'StoermeldeAnschluss'] as $property) {
$wert = json_decode($this->ReadPropertyString($property), true, 512, JSON_THROW_ON_ERROR);
if (!is_array($wert)) {
@@ -1423,6 +1459,114 @@ class Manager extends IPSModule implements ManagerSchnittstelle
}
}
/** @return array<string, mixed> */
private function leseAnlagentopologie(): array
{
return Anlagentopologie::ausJson(
$this->ReadPropertyString('AnlagenWechselrichter'),
$this->ReadPropertyString('AnlagenPVFlaechen'),
$this->ReadPropertyString('AnlagenBatterien'),
$this->ReadAttributeString('LizenzInstallationID')
);
}
private function hatAnlagentopologie(): bool
{
$topologie = $this->leseAnlagentopologie();
return $topologie['inverters'] !== []
|| $topologie['pvArrays'] !== []
|| $topologie['batteries'] !== [];
}
private function synchronisiereAnlagentopologie(): void
{
try {
$topologie = $this->leseAnlagentopologie();
if ($topologie['inverters'] === []
&& $topologie['pvArrays'] === []
&& $topologie['batteries'] === []
) {
$this->setzeDiagnosewert('Prognosestatus', 'NichtVerwendet');
return;
}
$token = trim($this->ReadAttributeString('PrognoseInstallationsToken'));
if ($token === '') {
throw new RuntimeException('Der Geraetezugang fuer die Prognose fehlt.');
}
$this->sendeAnlagentopologie($topologie, $token);
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
} catch (Throwable $fehler) {
$meldung = substr($fehler->getMessage(), 0, 500);
$this->setzeDiagnosewert('Prognosestatus', 'Fehler: ' . $meldung);
$this->protokolliere('Prognosesynchronisation', $meldung);
}
}
/** @param array<string, mixed> $topologie */
private function sendeAnlagentopologie(array $topologie, string $token): void
{
$url = sprintf(
self::PROGNOSE_TOPOLOGIE_ENDPOINT,
rawurlencode($this->ReadAttributeString('LizenzInstallationID'))
);
$nutzlast = json_encode(
$topologie,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Die Prognosesynchronisation konnte nicht vorbereitet werden.');
}
try {
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $nutzlast,
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 Prognosesynchronisation 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(
'Prognosesynchronisation HTTP ' . $httpStatus . ': ' . $meldung
);
}
}
/** @return array<int, float> */
private function dekodiereMonatsgrenzen(string $json): array
{
+1
View File
@@ -102,6 +102,7 @@ der Uebernahme in einen Freigabebranch erfolgreich sein.
- [Manager-Verbraucher-Schnittstelle](docs/Schnittstelle.md)
- [Batterieschnittstelle](docs/Schnittstelle-Batterie.md)
- [ADR 0004: Betriebsartabhaengige Leistungsangebote](docs/adr/0004-betriebsartabhaengige-leistungsangebote.md)
- [ADR 0005: Anlagentopologie fuer Prognosen](docs/adr/0005-anlagentopologie-fuer-prognosen.md)
- [Manager-Modul inklusive Lizenzierung](docs/module/Manager/README.md)
- [Warmwassererwaermer-Modul](docs/module/Wassererwaermer/README.md)
- [Pufferspeicher-Modul](docs/module/Pufferspeicher/README.md)
@@ -0,0 +1,76 @@
# ADR 0005: Anlagentopologie als Manager-Stammdaten
## Kontext
Die Prognose benoetigt technische Stammdaten zu PV-Flaechen,
Wechselrichtern und Batteriespeichern. Diese Daten beschreiben die reale
Installation und werden auch von der lokalen Regelung benoetigt. Eine
unabhaengige Pflege im Prognoseportal wuerde zwei konkurrierende Wahrheiten
erzeugen.
Hybridwechselrichter benoetigen eine ausdrueckliche Topologie. PV und Batterie
duerfen nicht als zwei unabhaengige AC-Quellen mit jeweils voller
Wechselrichterleistung behandelt werden.
## Entscheidung
Der Enelix-Manager ist die fuehrende Quelle fuer die technische
Anlagentopologie. Er speichert drei getrennte Listen:
- Wechselrichter mit Typ, AC-Nennleistung und optionalen AC-Grenzen,
- PV-Flaechen mit DC-Leistung, Ausrichtung und Wechselrichterbezug,
- Batteriespeicher mit Kapazitaeten, Leistungen, Kopplung und
Wechselrichterbezug.
Jede Komponente besitzt eine innerhalb ihrer Liste eindeutige, stabile ID.
PV-Flaechen und Batterien referenzieren einen Wechselrichter ueber diese ID.
Die AC-Nennleistung gehoert zum Wechselrichter. Die DC-Leistung gehoert zur
PV-Flaeche. Modulanzahl und Modulleistung sind optionale Detailangaben und
muessen gemeinsam gepflegt werden.
Bei einem Hybridwechselrichter bleiben PV-Flaechen und Batterie getrennte
Komponenten. Der Exportvertrag liefert zusaetzlich eine gemeinsame AC-Grenze,
die beide Seiten demselben Wechselrichter zuordnet. Damit darf ihre kombinierte
AC-Leistung die Wechselrichtergrenze nicht unabhaengig mehrfach ausschoepfen.
Der Manager exportiert die normalisierte Struktur mit Vertragsversion '1.0'
und seiner stabilen Lizenz-Installations-ID. Eine aggregierte Zusammenfassung
erleichtert die schrittweise Anbindung bestehender Prognoseberechnungen.
Tarife, Prognosevarianten und rein prognosespezifische Annahmen bleiben im
Prognoseportal. Dort werden die vom Manager gelieferten technischen Stammdaten
standardmaessig nur angezeigt.
## Alternativen
### Vollstaendige Pflege im Prognoseportal
Diese Variante waere fuer den Prognosedienst einfach, erzeugt aber
Doppelpflege und kann von der lokal tatsaechlich installierten Anlage
abweichen.
### Batterie und PV als ein Hybridobjekt speichern
Ein einzelnes Objekt waere kompakt, bildet mehrere PV-Flaechen, MPPT-Eingaenge
und spaetere Erweiterungen jedoch schlecht ab. Ausserdem gingen die getrennten
Kapazitaets- und Leistungsgrenzen verloren.
### AC- und DC-Leistung an jeder PV-Flaeche speichern
Das wuerde bei mehreren Flaechen an einem Wechselrichter die AC-Leistung
mehrfach zaehlen. Deshalb wird AC am Wechselrichter und DC an der Flaeche
gespeichert.
## Folgen
- Bestehende Installationen bleiben mit drei leeren Listen gueltig.
- Fehlerhafte Referenzen oder unplausible technische Grenzen sperren die
Manager-Konfiguration kontrolliert.
- Ein Hybridwechselrichter kann mehrere PV-Flaechen und Batteriespeicher
verbinden, besitzt aber nur eine gemeinsame AC-Nennleistung.
- Das Prognoseportal muss die technischen Stammdaten aus dem Managervertrag
lesen und eigene Eingaben dafuer als schreibgeschuetzt behandeln.
- Fuer den automatischen Upload ist ein eigener, widerrufbarer
Installationszugang erforderlich. Der Lizenzcode selbst wird nicht als
dauerhaftes API-Secret verwendet.
+30
View File
@@ -47,6 +47,9 @@ 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. |
| `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. |
| `SDLAnschluss` | String/JSON | Optionaler SDL/VGT-Anschluss. |
| `Lizenzcode` | String | Im Enelix-Lizenzportal erworbener Aktivierungscode. |
@@ -60,6 +63,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.
## Anlagentopologie und Prognoseexport
Der Manager ist die fuehrende Quelle fuer die technischen Stammdaten. Die
Konfiguration trennt Wechselrichter, PV-Flaechen und Batteriespeicher. AC-Leistung
wird am Wechselrichter, DC-Leistung an der PV-Flaeche gepflegt. Modulanzahl und
Modulleistung sind optional und muessen gemeinsam gesetzt werden.
PV-Flaechen und Batterien referenzieren ihren Wechselrichter ueber dessen stabile
ID. Bei `hybrid` koennen beide denselben Wechselrichter verwenden. Der Export
`ENELIX_AnlagentopologieExportieren($InstanzID)` weist dann eine gemeinsame
AC-Grenze aus, damit PV und Batterie die Nennleistung nicht unabhaengig doppelt
beanspruchen.
Der JSON-Export enthaelt die Vertragsversion `1.0`, die Lizenz-Installations-ID,
die drei Komponentenlisten, gemeinsame AC-Grenzen und aggregierte Summen fuer
die schrittweise Prognoseanbindung. Tarif- und Variantenparameter bleiben im
Prognoseportal. Eine nichtleere Topologie wird beim Speichern automatisch ueber
einen separaten, widerrufbaren Installationszugang synchronisiert. Der
Lizenzserver liefert diesen Zugang nur auf ausdrueckliche Geraeteanforderung;
der Manager entfernt ihn vor dem Speichern aus der Lease und haelt ihn in einem
internen Attribut. Der Lizenzcode wird nicht als API-Token verwendet. Ein
Synchronisationsfehler erscheint im `Prognosestatus`, blockiert die lokale
EMS-Regelung aber nicht.
Die Entscheidung und ihre Alternativen sind in
[ADR 0005](../../adr/0005-anlagentopologie-fuer-prognosen.md) dokumentiert.
## Lizenzierung
Der Manager arbeitet nur mit einer gueltigen Manager-Lizenz. Das Lizenzfeld steht
+428
View File
@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use JsonException;
final class Anlagentopologie
{
public const VERSION = '1.0';
/**
* @return array<string, mixed>
* @throws JsonException
*/
public static function ausJson(
string $wechselrichterJson,
string $pvFlaechenJson,
string $batterienJson,
string $installationID
): array {
$wechselrichter = self::dekodiereListe($wechselrichterJson, 'AnlagenWechselrichter');
$pvFlaechen = self::dekodiereListe($pvFlaechenJson, 'AnlagenPVFlaechen');
$batterien = self::dekodiereListe($batterienJson, 'AnlagenBatterien');
$inverters = [];
$inverterById = [];
foreach ($wechselrichter as $index => $eintrag) {
$pfad = 'Wechselrichter ' . ($index + 1);
$id = self::kennung($eintrag['ID'] ?? null, $pfad . ': ID');
if (isset($inverterById[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$typ = self::auswahl(
$eintrag['Typ'] ?? null,
['pv', 'battery', 'hybrid'],
$pfad . ': Typ'
);
$acRatedKw = self::positiveZahl(
$eintrag['ACNennleistung_kW'] ?? null,
$pfad . ': AC-Nennleistung'
);
$inverter = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'type' => $typ,
'acRatedKw' => $acRatedKw,
'acExportLimitKw' => self::optionaleGrenze(
$eintrag['ACEinspeisegrenze_kW'] ?? null,
$acRatedKw,
$pfad . ': AC-Einspeisegrenze'
),
'acImportLimitKw' => self::optionaleGrenze(
$eintrag['ACBezugsgrenze_kW'] ?? null,
$acRatedKw,
$pfad . ': AC-Bezugsgrenze'
),
];
$inverters[] = $inverter;
$inverterById[$id] = $inverter;
}
$pvArrays = [];
$pvIds = [];
foreach ($pvFlaechen as $index => $eintrag) {
$pfad = 'PV-Flaeche ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $pvIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
if (!in_array($inverter['type'], ['pv', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': benoetigt einen PV- oder Hybridwechselrichter.'
);
}
$moduleCount = self::optionalePositiveGanzzahl(
$eintrag['Modulanzahl'] ?? null,
$pfad . ': Modulanzahl'
);
$modulePowerWp = self::optionalePositiveZahl(
$eintrag['Modulleistung_Wp'] ?? null,
$pfad . ': Modulleistung'
);
if (($moduleCount === null) !== ($modulePowerWp === null)) {
throw new InvalidArgumentException(
$pfad . ': Modulanzahl und Modulleistung muessen gemeinsam gepflegt werden.'
);
}
$pvArrays[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'dcKwp' => self::positiveZahl(
$eintrag['DCLeistung_kWp'] ?? null,
$pfad . ': DC-Leistung'
),
'tiltDeg' => self::zahlImBereich(
$eintrag['Neigung_Grad'] ?? null,
0.0,
90.0,
$pfad . ': Neigung'
),
'azimuthDeg' => self::zahlImBereich(
$eintrag['Azimut_Grad'] ?? null,
-180.0,
180.0,
$pfad . ': Azimut'
),
'inverterId' => $inverterId,
'mpptInput' => self::optionalerText($eintrag['MPPT'] ?? null),
'moduleCount' => $moduleCount,
'modulePowerWp' => $modulePowerWp,
];
}
$batteryItems = [];
$batteryIds = [];
foreach ($batterien as $index => $eintrag) {
$pfad = 'Batterie ' . ($index + 1);
$id = self::eindeutigeKennung($eintrag['ID'] ?? null, $batteryIds, $pfad);
$inverterId = self::kennung(
$eintrag['WechselrichterID'] ?? null,
$pfad . ': Wechselrichter-ID'
);
$inverter = self::wechselrichter($inverterById, $inverterId, $pfad);
$kopplung = self::auswahl(
$eintrag['Kopplung'] ?? null,
['ac', 'dc', 'hybrid'],
$pfad . ': Kopplung'
);
if ($kopplung === 'ac' && !in_array($inverter['type'], ['battery', 'hybrid'], true)) {
throw new InvalidArgumentException(
$pfad . ': AC-Kopplung benoetigt einen Batterie- oder Hybridwechselrichter.'
);
}
if ($kopplung !== 'ac' && $inverter['type'] !== 'hybrid') {
throw new InvalidArgumentException(
$pfad . ': DC- und Hybridkopplung benoetigen einen Hybridwechselrichter.'
);
}
$nominalCapacityKwh = self::positiveZahl(
$eintrag['Nennkapazitaet_kWh'] ?? null,
$pfad . ': Nennkapazitaet'
);
$usableCapacityKwh = self::positiveZahl(
$eintrag['Nutzkapazitaet_kWh'] ?? null,
$pfad . ': Nutzkapazitaet'
);
if ($usableCapacityKwh > $nominalCapacityKwh) {
throw new InvalidArgumentException(
$pfad . ': Nutzkapazitaet darf die Nennkapazitaet nicht uebersteigen.'
);
}
$batteryItems[] = [
'id' => $id,
'name' => self::name($eintrag['Name'] ?? null, $id),
'nominalCapacityKwh' => $nominalCapacityKwh,
'usableCapacityKwh' => $usableCapacityKwh,
'maxChargeKw' => self::positiveZahl(
$eintrag['MaxLadeleistung_kW'] ?? null,
$pfad . ': maximale Ladeleistung'
),
'maxDischargeKw' => self::positiveZahl(
$eintrag['MaxEntladeleistung_kW'] ?? null,
$pfad . ': maximale Entladeleistung'
),
'inverterId' => $inverterId,
'coupling' => $kopplung,
];
}
return [
'version' => self::VERSION,
'installationId' => trim($installationID),
'inverters' => $inverters,
'pvArrays' => $pvArrays,
'batteries' => $batteryItems,
'sharedAcLimits' => self::gemeinsameAcGrenzen(
$inverters,
$pvArrays,
$batteryItems
),
'summary' => self::zusammenfassung($inverters, $pvArrays, $batteryItems),
];
}
/** @return list<array<string, mixed>> */
private static function dekodiereListe(string $json, string $name): array
{
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($daten)
|| ($daten !== [] && array_keys($daten) !== range(0, count($daten) - 1))
) {
throw new InvalidArgumentException($name . ' muss eine JSON-Liste sein.');
}
foreach ($daten as $eintrag) {
if (!is_array($eintrag)) {
throw new InvalidArgumentException($name . ' enthaelt einen ungueltigen Eintrag.');
}
}
return $daten;
}
/** @param array<string, bool> $verwendet */
private static function eindeutigeKennung($wert, array &$verwendet, string $pfad): string
{
$id = self::kennung($wert, $pfad . ': ID');
if (isset($verwendet[$id])) {
throw new InvalidArgumentException($pfad . ': ID ist mehrfach vorhanden.');
}
$verwendet[$id] = true;
return $id;
}
private static function kennung($wert, string $feld): string
{
if (!is_string($wert)
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', trim($wert)) !== 1
) {
throw new InvalidArgumentException(
$feld . ' muss 1 bis 64 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.'
);
}
return trim($wert);
}
private static function name($wert, string $fallback): string
{
if ($wert === null || $wert === '') {
return $fallback;
}
if (!is_string($wert) || strlen(trim($wert)) > 120) {
throw new InvalidArgumentException('Name muss ein Text mit maximal 120 Zeichen sein.');
}
return trim($wert);
}
/** @param list<string> $werte */
private static function auswahl($wert, array $werte, string $feld): string
{
if (!is_string($wert) || !in_array($wert, $werte, true)) {
throw new InvalidArgumentException($feld . ' ist ungueltig.');
}
return $wert;
}
private static function positiveZahl($wert, string $feld): float
{
if (!is_int($wert) && !is_float($wert)) {
throw new InvalidArgumentException($feld . ' muss eine Zahl sein.');
}
if (!is_finite((float) $wert) || (float) $wert <= 0.0) {
throw new InvalidArgumentException($feld . ' muss groesser als 0 sein.');
}
return (float) $wert;
}
private static function optionalePositiveZahl($wert, string $feld): ?float
{
if ($wert === null || $wert === '' || $wert === 0 || $wert === 0.0) {
return null;
}
return self::positiveZahl($wert, $feld);
}
private static function optionalePositiveGanzzahl($wert, string $feld): ?int
{
if ($wert === null || $wert === '' || $wert === 0) {
return null;
}
if (!is_int($wert) || $wert <= 0) {
throw new InvalidArgumentException($feld . ' muss eine positive Ganzzahl sein.');
}
return $wert;
}
private static function zahlImBereich(
$wert,
float $minimum,
float $maximum,
string $feld
): float {
if ((!is_int($wert) && !is_float($wert))
|| !is_finite((float) $wert)
|| (float) $wert < $minimum
|| (float) $wert > $maximum
) {
throw new InvalidArgumentException(
sprintf('%s muss zwischen %g und %g liegen.', $feld, $minimum, $maximum)
);
}
return (float) $wert;
}
private static function optionaleGrenze($wert, float $nennleistung, string $feld): ?float
{
$grenze = self::optionalePositiveZahl($wert, $feld);
if ($grenze !== null && $grenze > $nennleistung) {
throw new InvalidArgumentException(
$feld . ' darf die AC-Nennleistung nicht uebersteigen.'
);
}
return $grenze;
}
private static function optionalerText($wert): ?string
{
if ($wert === null || $wert === '') {
return null;
}
if (!is_string($wert) || strlen(trim($wert)) > 64) {
throw new InvalidArgumentException('MPPT muss ein Text mit maximal 64 Zeichen sein.');
}
return trim($wert);
}
/**
* @param array<string, array<string, mixed>> $wechselrichter
* @return array<string, mixed>
*/
private static function wechselrichter(array $wechselrichter, string $id, string $pfad): array
{
if (!isset($wechselrichter[$id])) {
throw new InvalidArgumentException(
$pfad . ': referenzierter Wechselrichter ist nicht vorhanden.'
);
}
return $wechselrichter[$id];
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return list<array<string, mixed>>
*/
private static function gemeinsameAcGrenzen(
array $inverters,
array $pvArrays,
array $batteries
): array {
$result = [];
foreach ($inverters as $inverter) {
if ($inverter['type'] !== 'hybrid') {
continue;
}
$pvIds = [];
foreach ($pvArrays as $pvArray) {
if ($pvArray['inverterId'] === $inverter['id']) {
$pvIds[] = $pvArray['id'];
}
}
$batteryIds = [];
foreach ($batteries as $battery) {
if ($battery['inverterId'] === $inverter['id']) {
$batteryIds[] = $battery['id'];
}
}
if ($pvIds === [] && $batteryIds === []) {
continue;
}
$result[] = [
'inverterId' => $inverter['id'],
'acRatedKw' => $inverter['acRatedKw'],
'pvArrayIds' => $pvIds,
'batteryIds' => $batteryIds,
];
}
return $result;
}
/**
* @param list<array<string, mixed>> $inverters
* @param list<array<string, mixed>> $pvArrays
* @param list<array<string, mixed>> $batteries
* @return array<string, float>
*/
private static function zusammenfassung(
array $inverters,
array $pvArrays,
array $batteries
): array {
$pvInverterIds = [];
$pvDcKwp = 0.0;
foreach ($pvArrays as $pvArray) {
$pvDcKwp += $pvArray['dcKwp'];
$pvInverterIds[$pvArray['inverterId']] = true;
}
$pvAcKw = 0.0;
foreach ($inverters as $inverter) {
if (isset($pvInverterIds[$inverter['id']])) {
$pvAcKw += $inverter['acRatedKw'];
}
}
$usableBatteryKwh = 0.0;
$maxBatteryChargeKw = 0.0;
$maxBatteryDischargeKw = 0.0;
foreach ($batteries as $battery) {
$usableBatteryKwh += $battery['usableCapacityKwh'];
$maxBatteryChargeKw += $battery['maxChargeKw'];
$maxBatteryDischargeKw += $battery['maxDischargeKw'];
}
return [
'pvDcKwp' => $pvDcKwp,
'pvAcKw' => $pvAcKw,
'usableBatteryKwh' => $usableBatteryKwh,
'maxBatteryChargeKw' => $maxBatteryChargeKw,
'maxBatteryDischargeKw' => $maxBatteryDischargeKw,
];
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use Belevo\EnelixEMS\Anlagentopologie;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class AnlagentopologieTest extends TestCase
{
public function testHybridwechselrichterVerknuepftPvUndBatterieMitGemeinsamerAcGrenze(): void
{
$topologie = Anlagentopologie::ausJson(
json_encode([[
'ID' => 'hybrid-1',
'Name' => 'Hybrid Ost',
'Typ' => 'hybrid',
'ACNennleistung_kW' => 10.0,
'ACEinspeisegrenze_kW' => 8.0,
'ACBezugsgrenze_kW' => 0.0,
]], JSON_THROW_ON_ERROR),
json_encode([
[
'ID' => 'pv-ost',
'Name' => 'Ostdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => -90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'A',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
[
'ID' => 'pv-west',
'Name' => 'Westdach',
'DCLeistung_kWp' => 6.4,
'Neigung_Grad' => 25.0,
'Azimut_Grad' => 90.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'B',
'Modulanzahl' => 16,
'Modulleistung_Wp' => 400.0,
],
], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'bat-1',
'Name' => 'Hausspeicher',
'Nennkapazitaet_kWh' => 13.5,
'Nutzkapazitaet_kWh' => 12.8,
'MaxLadeleistung_kW' => 5.0,
'MaxEntladeleistung_kW' => 5.0,
'WechselrichterID' => 'hybrid-1',
'Kopplung' => 'hybrid',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame(Anlagentopologie::VERSION, $topologie['version']);
self::assertSame(12.8, $topologie['summary']['pvDcKwp']);
self::assertSame(10.0, $topologie['summary']['pvAcKw']);
self::assertSame(12.8, $topologie['summary']['usableBatteryKwh']);
self::assertSame([[
'inverterId' => 'hybrid-1',
'acRatedKw' => 10.0,
'pvArrayIds' => ['pv-ost', 'pv-west'],
'batteryIds' => ['bat-1'],
]], $topologie['sharedAcLimits']);
}
public function testLeereTopologieBleibtFuerBestandsinstallationenGueltig(): void
{
$topologie = Anlagentopologie::ausJson(
'[]',
'[]',
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
self::assertSame([], $topologie['inverters']);
self::assertSame([], $topologie['pvArrays']);
self::assertSame([], $topologie['batteries']);
self::assertSame(0.0, $topologie['summary']['pvDcKwp']);
}
public function testPvFlaecheBenoetigtEinenVorhandenenPassendenWechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('referenzierter Wechselrichter ist nicht vorhanden');
Anlagentopologie::ausJson(
'[]',
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 5.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'wr-fehlt',
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testDcBatterieBenoetigtEinenHybridwechselrichter(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('DC- und Hybridkopplung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'bat-wr',
'Typ' => 'battery',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
'[]',
json_encode([[
'ID' => 'bat-1',
'Nennkapazitaet_kWh' => 10.0,
'Nutzkapazitaet_kWh' => 9.0,
'MaxLadeleistung_kW' => 4.0,
'MaxEntladeleistung_kW' => 4.0,
'WechselrichterID' => 'bat-wr',
'Kopplung' => 'dc',
]], JSON_THROW_ON_ERROR),
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
public function testModuldatenMuessenGemeinsamGesetztWerden(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Modulanzahl und Modulleistung');
Anlagentopologie::ausJson(
json_encode([[
'ID' => 'pv-wr',
'Typ' => 'pv',
'ACNennleistung_kW' => 5.0,
]], JSON_THROW_ON_ERROR),
json_encode([[
'ID' => 'pv-1',
'DCLeistung_kWp' => 6.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'pv-wr',
'Modulanzahl' => 15,
'Modulleistung_Wp' => 0.0,
]], JSON_THROW_ON_ERROR),
'[]',
'2a518707-8d62-4d97-82ce-606c95726500'
);
}
}
+39
View File
@@ -56,6 +56,9 @@ final class ManagerModulstrukturTest extends TestCase
'LizenzInstallationID',
'LizenzInformation',
'VerbraucherAktualisieren',
'AnlagenWechselrichter',
'AnlagenPVFlaechen',
'AnlagenBatterien',
] as $property) {
self::assertStringContainsString($property, $json);
}
@@ -171,6 +174,40 @@ final class ManagerModulstrukturTest extends TestCase
self::assertStringNotContainsString("'publicKey' =>", $inhalt);
}
public function testManagerSynchronisiertDieTopologieMitSeparatemGeraetezugang(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString(
'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology',
$inhalt
);
self::assertStringContainsString(
"RegisterAttributeString('PrognoseInstallationsToken', '')",
$inhalt
);
self::assertStringContainsString("'issueDeviceToken' =>", $inhalt);
self::assertStringContainsString("unset(\$neueLease['deviceToken'])", $inhalt);
self::assertStringContainsString('synchronisiereAnlagentopologie()', $inhalt);
self::assertStringContainsString("'Authorization: Bearer ' . \$token", $inhalt);
self::assertStringNotContainsString(
"protokolliere('Prognosesynchronisation', \$token)",
$inhalt
);
$leasePruefung = strpos($inhalt, 'Lizenzpruefung::pruefeLease(');
$tokenSpeicherung = strpos(
$inhalt,
"WriteAttributeString('PrognoseInstallationsToken', \$geraeteToken)"
);
self::assertNotFalse($leasePruefung);
self::assertNotFalse($tokenSpeicherung);
self::assertTrue(
$leasePruefung < $tokenSpeicherung,
'Der Gerätezugang darf erst nach validierter Lizenzantwort gespeichert werden.'
);
}
public function testManagerVerwendetDieNeuenZeitdefaults(): void
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
@@ -193,6 +230,8 @@ final class ManagerModulstrukturTest extends TestCase
{
$inhalt = (string) file_get_contents(__DIR__ . '/../Manager/module.php');
self::assertStringContainsString('implements ManagerSchnittstelle', $inhalt);
self::assertStringContainsString("require_once __DIR__ . '/../libs/Anlagentopologie.php'", $inhalt);
self::assertStringContainsString('public function AnlagentopologieExportieren(): string', $inhalt);
self::assertStringContainsString('VerbraucherdatenEmpfangen(array $daten)', $inhalt);
self::assertStringContainsString("'ManagerdatenEmpfangen'", $inhalt);
}
+50
View File
@@ -72,6 +72,35 @@ return static function (TestContext $test): void {
['InstanzID' => $warmwasserId, 'Aktiv' => true],
['InstanzID' => $pufferspeicherId, 'Aktiv' => true],
];
$wechselrichterKonfiguration = [[
'ID' => 'hybrid-1',
'Name' => 'Hybridwechselrichter',
'Typ' => 'hybrid',
'ACNennleistung_kW' => 10.0,
'ACEinspeisegrenze_kW' => 8.0,
'ACBezugsgrenze_kW' => 0.0,
]];
$pvKonfiguration = [[
'ID' => 'pv-1',
'Name' => 'Suedflaeche',
'DCLeistung_kWp' => 12.0,
'Neigung_Grad' => 30.0,
'Azimut_Grad' => 0.0,
'WechselrichterID' => 'hybrid-1',
'MPPT' => 'A',
'Modulanzahl' => 30,
'Modulleistung_Wp' => 400.0,
]];
$batterieKonfiguration = [[
'ID' => 'bat-1',
'Name' => 'Hausspeicher',
'Nennkapazitaet_kWh' => 13.5,
'Nutzkapazitaet_kWh' => 12.8,
'MaxLadeleistung_kW' => 5.0,
'MaxEntladeleistung_kW' => 5.0,
'WechselrichterID' => 'hybrid-1',
'Kopplung' => 'hybrid',
]];
$lizenzcode = trim((string) getenv('ENELIX_TEST_LICENSE_CODE'));
$managerId = $test->createInstance(
'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',
@@ -87,6 +116,12 @@ return static function (TestContext $test): void {
'Lastspitzengrenze' => 0.0,
'SollwertSolarladen' => 0.0,
'Umschaltdifferenz' => 0.0,
'AnlagenWechselrichter' => json_encode(
$wechselrichterKonfiguration,
JSON_THROW_ON_ERROR
),
'AnlagenPVFlaechen' => json_encode($pvKonfiguration, JSON_THROW_ON_ERROR),
'AnlagenBatterien' => json_encode($batterieKonfiguration, JSON_THROW_ON_ERROR),
'DiagnosevariablenAnzeigen' => true,
]
);
@@ -163,6 +198,21 @@ return static function (TestContext $test): void {
$vorher = $lizenzInstallationID();
IPS_ApplyChanges($managerId);
$test->assertSame($vorher, $lizenzInstallationID());
$topologie = json_decode(
ENELIX_AnlagentopologieExportieren($managerId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertSame($vorher, $topologie['installationId'] ?? null);
$test->assertSame('1.0', $topologie['version'] ?? null);
$test->assertSame(12.0, $topologie['summary']['pvDcKwp'] ?? null);
$test->assertSame(10.0, $topologie['summary']['pvAcKw'] ?? null);
$test->assertSame(12.8, $topologie['summary']['usableBatteryKwh'] ?? null);
$test->assertSame('hybrid-1', $topologie['sharedAcLimits'][0]['inverterId'] ?? null);
$test->assertSame(['pv-1'], $topologie['sharedAcLimits'][0]['pvArrayIds'] ?? null);
$test->assertSame(['bat-1'], $topologie['sharedAcLimits'][0]['batteryIds'] ?? null);
});
$zustandswert = static function (array $daten, int $instanzId, string $kennung) {
foreach ($daten['Verbraucher'] ?? [] as $verbraucher) {