Files
Enelix-EMS/libs/Anlagentopologie.php
T
2026-09-28 17:47:22 +00:00

407 lines
14 KiB
PHP

<?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,
];
$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 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,
];
}
}