$max) { throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.'); } return (float) $value; } private static function timestamp($value): int { if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) { throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.'); } return (new DateTimeImmutable($value))->getTimestamp(); } /** $read returns ['value' => int|float, 'updated' => int], without changing a device. */ private static function measurement(callable $read, int $id, int $now, int $age, string $label): array { if ($id <= 0) { throw new InvalidArgumentException($label . ': Messquelle fehlt.'); } $m = $read($id); if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now || $now - $m['updated'] > $age) { throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.'); } return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']]; } /** No inference of paid peak or elapsed energy from power, reserve or forecast. */ private static function evidence(array $input, int $now, int $meterID, array &$warnings): array { $peaks = []; $past = null; if ($input === []) { $warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.'; return [(object) [], null]; } if ($meterID <= 0 || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== 'symcon:' . $meterID) { throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.'); } $measured = self::timestamp($input['measuredAt'] ?? null); if ($measured > $now || $now - $measured > 120) { throw new InvalidArgumentException('Messnachweis ist nicht aktuell.'); } $month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m'); foreach (($input['measuredPeaks'] ?? []) as $key => $p) { if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month || !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) { throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.'); } $peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']]; } if (!array_key_exists($month, $peaks)) { $warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.'; } $q = intdiv($now, 900) * 900; if (isset($input['quarterPast'])) { $p = $input['quarterPast']; // Stale quarter evidence remains absent, not extrapolated to the decision time. if ($measured === $now && self::timestamp($p['start'] ?? null) === $q && ($p['measuredSeconds'] ?? null) === $now - $q) { $past = ['start' => gmdate('c', $q), 'measuredSeconds' => $now - $q, 'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)]; } else { $warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.'; } } if ($now !== $q && $past === null) { $warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.'; } return [(object) $peaks, $past]; } /** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */ public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array { $warnings = []; $grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now, (int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung'); $gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor'); $mode = $manager['Lastspitzenmodus'] ?? 0; $import = null; $months = []; if ($mode === 1) { $import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0); } elseif ($mode === 2) { foreach (($manager['Monatsgrenzen'] ?? []) as $row) { $m = $row['MonatIndex'] ?? null; if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) { throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.'); } $months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0); } if (count($months) !== 12) { throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.'); } } elseif ($mode !== 0) { throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.'); } $export = !empty($manager['EinspeisebegrenzungAktiv']) ? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null; $result = []; $seen = []; $used = []; foreach ($assets as $asset) { $id = $asset['ID'] ?? ''; if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) { throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.'); } $seen[$id] = true; $socID = (int) ($asset['SOCVariableID'] ?? 0); $matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool { return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID; })); if (count($matches) !== 1) { throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.'); } $c = $matches[0]; $controllerID = $c['InstanzID']; if (isset($used[$controllerID])) { throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.'); } $used[$controllerID] = true; if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) { throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.'); } $opts = $manager['BatterieOptionen'][$id] ?? []; $nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001); $usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal); if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) { throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.'); } $capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal); $age = (int) ($c['MesswertMaxAlter'] ?? 30); $soc = self::measurement($read, $socID, $now, $age, $id . ' SOC'); $charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden'); $discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen'); $min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100), self::number($c['ReserveLadezustand'], 'Betriebsreserve', 0, 100)); $max = self::number($opts['MaxSOC_Prozent'] ?? 100.0, 'Maximal-SOC', $min, 100); $socValue = self::number($soc['value'], 'SOC', 0, 100); if ($socValue < $min || $socValue > $max) { throw new InvalidArgumentException($id . ': SOC ausserhalb des Planungsbereichs; kein kuenstliches Anheben.'); } if (($c['EmpfangenAm'] ?? 0) > $now || $now - ($c['EmpfangenAm'] ?? 0) > (int) ($manager['VerbraucherTimeout'] ?? 60)) { throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.'); } $maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0)); $maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0)); if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) { $maxCharge = $maxDischarge = 0.0; $warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.'; } if (!empty($c['HystereseAktiv'])) { $maxDischarge = 0.0; $warnings[] = $id . ': aktuelle Entladesperre konservativ uebernommen; keine spaetere Freigabe erfunden.'; } $result[] = ['id' => $id, 'capacityKwh' => $capacity, 'socPercent' => $socValue, 'minSocPercent' => $min, 'maxSocPercent' => $max, 'maxChargeW' => $maxCharge, 'maxDischargeW' => $maxDischarge, 'measuredAt' => gmdate('c', min($soc['updated'], $charge['updated'], $discharge['updated'])), 'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true]; } if ($result === []) { throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.'); } [$peaks, $past] = self::evidence($evidence, $now, (int) ($manager['NetzbezugEnergieVariableID'] ?? 0), $warnings); $payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW, 'meteringBoundary' => 'common_pcc', 'batteries' => $result, 'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months], 'measuredPeaks' => $peaks, 'quarterPast' => $past]; $payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'operation' => $payload]; } }