99999) { throw new InvalidArgumentException($label . ': gueltige Objektzuordnung fehlt.'); } return $value; } private static function factor($value): float { if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value == 0 || abs($value) > 1.0e6) { throw new InvalidArgumentException('Expliziter Leistungsfaktor fehlt oder ist ungueltig.'); } return (float) $value; } private static function source(string $key, string $role, $id, $factor, int $age, callable $read): array { $id = self::objectId($id, $role); try { $object = $read($id); } catch (Throwable $e) { // Reader exceptions may contain connection settings; never propagate them. throw new InvalidArgumentException('Messquelle ' . $id . ' ist nicht verfuegbar.'); } if (!is_array($object) || !in_array($object['type'] ?? null, [1, 2], true) || !is_int($object['parentID'] ?? null) || $object['parentID'] < 1 || $object['parentID'] > 99999 || !is_string($object['ident'] ?? null) || strlen($object['ident']) > 100) { throw new InvalidArgumentException('Messquelle ' . $id . ' ist keine eindeutig zugeordnete numerische Variable.'); } if (($object['virtual'] ?? false) !== false || ($object['sharedStorage'] ?? false) !== false) { throw new InvalidArgumentException('Virtuelle oder geteilte Speicher benoetigen eine explizite Messzuordnung.'); } return ['key' => $key, 'role' => $role, 'variableId' => $id, 'parentId' => $object['parentID'], 'ident' => $object['ident'], 'factorToW' => self::factor($factor), 'maxAgeSeconds' => $age, 'dependsOn' => []]; } /** objectReader(int): {parentID:int, ident:string, type:int, virtual?:bool, sharedStorage?:bool}. * type is IPS VariableType (1=int, 2=float), not ObjectType. Readers verify * the object really is a variable and reject derived/virtual source parents. * No values, timestamps, full instance settings or credentials are needed. * * @return array{capture:array,dataset:array,datasetId:string,mappingSha256:string,inventorySha256:string} */ public static function erstellen( string $installationId, int $managerId, array $manager, array $inverters, array $batteries, callable $objectReader ): array { if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $installationId)) { throw new InvalidArgumentException('Gueltige Installationskennung erforderlich.'); } self::objectId($managerId, 'Manager'); $age = $manager['MesswertMaxAlter'] ?? null; if (!is_int($age) || $age < 1) { throw new InvalidArgumentException('Maximales Messwertalter fehlt.'); } $age = min(60, $age); if (($manager['SDLAktiv'] ?? false) !== false || ($manager['SDLLeistungVariableID'] ?? 0) !== 0) { throw new InvalidArgumentException('SDL-Anlage benoetigt eine explizite physische Messzuordnung.'); } if (array_keys($inverters) !== array_keys(array_values($inverters)) || array_keys($batteries) !== array_keys(array_values($batteries)) || $inverters === [] || $batteries === [] || count($inverters) + count($batteries) > 79) { throw new InvalidArgumentException('Vollstaendige Wechselrichter- und Batterietopologie erforderlich.'); } $sources = [self::source('grid', 'grid', $manager['NetzleistungVariableID'] ?? null, $manager['Netzleistungsfaktor'] ?? null, $age, $objectReader)]; $inverterMap = []; foreach ($inverters as $inverter) { if (!is_array($inverter)) { throw new InvalidArgumentException('Ungueltiger Wechselrichtereintrag.'); } $id = self::identifier($inverter['ID'] ?? null, 'Wechselrichter'); if (isset($inverterMap[$id]) || !in_array($inverter['Typ'] ?? null, ['pv', 'battery'], true)) { throw new InvalidArgumentException('Doppelte oder hybride Wechselrichter benoetigen eine explizite Messzuordnung.'); } $inverterMap[$id] = $inverter['Typ']; if ($inverter['Typ'] === 'pv') { $sources[] = self::source('pv_' . substr(hash('sha256', $id), 0, 24), 'pv', $inverter['IstleistungVariableID'] ?? null, $inverter['Istleistungsfaktor'] ?? null, $age, $objectReader); } } $batteryMap = []; $usedInverters = []; foreach ($batteries as $battery) { if (!is_array($battery)) { throw new InvalidArgumentException('Ungueltiger Batterieeintrag.'); } $id = self::identifier($battery['ID'] ?? null, 'Batterie'); $inverterId = self::identifier($battery['WechselrichterID'] ?? null, 'Batteriewechselrichter'); if (isset($batteryMap[$id]) || ($battery['Kopplung'] ?? null) !== 'ac' || ($inverterMap[$inverterId] ?? null) !== 'battery' || isset($usedInverters[$inverterId])) { throw new InvalidArgumentException('Batterie benoetigt einen eigenen AC-Wandler und eine eindeutige Leistungsmessung.'); } if (($battery['Virtuell'] ?? false) !== false || ($battery['SDLAktiv'] ?? false) !== false) { throw new InvalidArgumentException('Virtuelle oder SDL-Batterie benoetigt eine explizite Messzuordnung.'); } $batteryMap[$id] = $inverterId; $usedInverters[$inverterId] = true; $sources[] = self::source('battery_' . substr(hash('sha256', $id), 0, 24), 'physical_storage', $battery['LeistungVariableID'] ?? null, $battery['Leistungsfaktor'] ?? null, $age, $objectReader); } foreach ($inverterMap as $id => $type) { if ($type === 'battery' && !isset($usedInverters[$id])) { throw new InvalidArgumentException('Nicht zugeordneter Batteriewechselrichter in der Anlagenbilanz.'); } } $accounting = NetzfahrplanV4Bilanzierung::konfigurieren([ 'version' => 1, 'sources' => $sources, 'maxSkewSeconds' => min(30, $age), 'splitToleranceW' => 0.0, 'storageMode' => 'physical_only', ]); ksort($inverterMap); ksort($batteryMap); $inventory = ['version' => 1, 'installationId' => $installationId, 'managerId' => $managerId, 'inverters' => $inverterMap, 'batteries' => $batteryMap, 'sources' => $accounting['sources']]; $inventoryHash = hash('sha256', json_encode($inventory, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); $capture = NetzfahrplanV4Messaufnahme::configuration([ 'version' => 1, 'installationId' => $installationId, 'managerId' => $managerId, 'reportedInventorySha256' => $inventoryHash, 'accounting' => $accounting, 'extraSources' => [], ]); $mappingHash = hash('sha256', json_encode($capture, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)); $datasetId = 'v4-' . substr($mappingHash, 0, 32); $dataset = ['datasetId' => $datasetId, 'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash, 'sources' => array_map(static fn(array $s): array => [ 'key' => $s['key'], 'variableId' => $s['variableId'], 'role' => $s['role'], 'factorToW' => $s['factorToW'], 'maxAgeSeconds' => $s['maxAgeSeconds'], ], $capture['accounting']['sources']), 'formula' => 'physical_sum_v1', 'solarReference' => null, 'minimumCoverage' => 0.95, 'maximumGapSeconds' => 5, 'minimumTrainingHours' => 24, 'historyDays' => 30]; return ['capture' => $capture, 'dataset' => $dataset, 'datasetId' => $datasetId, 'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash]; } }