feat(manager): receive and preview V4 shadow plans without dispatch
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
|
||||
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
|
||||
trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
{
|
||||
private function registriereNetzfahrplanV4Empfang(): void
|
||||
{
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4EmpfangAktiv', false);
|
||||
$this->RegisterAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
|
||||
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950);
|
||||
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951);
|
||||
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
|
||||
}
|
||||
|
||||
private function stoppeNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', 0);
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', '{"status":"not_received"}');
|
||||
$this->zeigeNetzfahrplanV4Vorschau(['status' => 'not_received', 'reason' => 'Neu initialisiert; keinen alten Sollwert weiterverwenden.']);
|
||||
}
|
||||
|
||||
private function konfiguriereNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|
||||
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|
||||
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
|
||||
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
|
||||
$this->aktualisiereNetzfahrplanV4Vorschau();
|
||||
}
|
||||
|
||||
/** Appended dynamically; existing form.json and parallel portal GUI stay untouched. */
|
||||
private function netzfahrplanV4EmpfangFormular(): array
|
||||
{
|
||||
return ['type' => 'ExpansionPanel', 'caption' => 'V4 Planempfang / Sollwert-Vorschau', 'items' => [
|
||||
['type' => 'Label', 'caption' => 'NUR BEOBACHTEN. Kein Lade-/Entladebefehl. Der alte intelligente Netzfahrplan muss AUS bleiben.'],
|
||||
['type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv', 'caption' => 'V4 Schattenplan empfangen und Sollwert anzeigen (keine Steuerung)'],
|
||||
['type' => 'Label', 'caption' => 'Diagnose und Sollwerte stehen in den beiden V4-Vorschauvariablen unterhalb des Managers.'],
|
||||
['type' => 'Button', 'caption' => 'Plan jetzt pruefen (Abruf begrenzt auf 1/min)',
|
||||
'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);"],
|
||||
]];
|
||||
}
|
||||
|
||||
/** Called before the next operation upload, so the preceding completed plan can be seen. */
|
||||
private function empfangeNetzfahrplanV4(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
|
||||
$lock = 'ENELIX.V4.Receive.' . $this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock, 0)) return;
|
||||
try {
|
||||
$now = time();
|
||||
if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return;
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + 60);
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.');
|
||||
}
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
}
|
||||
$response = $this->netzfahrplanV4EmpfangHttp('GET', '');
|
||||
$receipt = time();
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
|
||||
}
|
||||
// Clear the old envelope before validation: invalid replies never extend its lifetime.
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($response,
|
||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $receipt);
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
// Only the known, non-secret protocol envelope is cached.
|
||||
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh',
|
||||
'pending', 'settings', 'plan', 'families', 'lastRun'];
|
||||
$safe = array_intersect_key($response, array_flip($keys));
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
|
||||
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
|
||||
$state = ['status' => 'received_shadow', 'at' => gmdate('c', $receipt), 'acknowledged' => false];
|
||||
$ack = ['planId' => $preview['planId'], 'revision' => $preview['revision'], 'step' => $preview['step'], 'status' => 'shadow_seen'];
|
||||
$last = json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteBestaetigung'), true);
|
||||
if ($last !== $ack) {
|
||||
try {
|
||||
$answer = $this->netzfahrplanV4EmpfangHttp('POST', '/ack', $ack);
|
||||
if (($answer['status'] ?? null) !== 'recorded') throw new RuntimeException('Empfangsbestaetigung abgelehnt.');
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', json_encode($ack, JSON_THROW_ON_ERROR));
|
||||
$state['acknowledged'] = true;
|
||||
} catch (Throwable $error) {
|
||||
$state['acknowledgementError'] = substr($error->getMessage(), 0, 200);
|
||||
}
|
||||
} else {
|
||||
$state['acknowledged'] = true;
|
||||
}
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$this->aktualisiereNetzfahrplanV4Vorschau();
|
||||
} catch (Throwable $error) {
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
$state = ['status' => 'rejected_or_unavailable', 'at' => gmdate('c'), 'reason' => substr($error->getMessage(), 0, 300)];
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$this->zeigeNetzfahrplanV4Vorschau($state);
|
||||
} finally {
|
||||
IPS_SemaphoreLeave($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed authenticated read/ack endpoints only; never operation, settings, replan or dispatch. */
|
||||
private function netzfahrplanV4EmpfangHttp(string $method, string $suffix, ?array $body = null): array
|
||||
{
|
||||
if (!(($method === 'GET' && $suffix === '' && $body === null)
|
||||
|| ($method === 'POST' && $suffix === '/ack' && ($body['status'] ?? null) === 'shadow_seen'))) {
|
||||
throw new RuntimeException('Unzulaessiger V4-Empfangsaufruf.');
|
||||
}
|
||||
$id = $this->ReadAttributeString('LizenzInstallationID');
|
||||
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $id)
|
||||
|| !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
|
||||
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
|
||||
}
|
||||
$handle = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
|
||||
if ($handle === false) throw new RuntimeException('V4-Empfang konnte nicht vorbereitet werden.');
|
||||
$response = '';
|
||||
try {
|
||||
$options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token,
|
||||
'Accept: application/json', 'Content-Type: application/json', 'Cache-Control: no-cache'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
|
||||
if (strlen($response) + strlen($chunk) > NetzfahrplanV4Planpruefung::MAX_RESPONSE_BYTES) return 0;
|
||||
$response .= $chunk;return strlen($chunk);
|
||||
}];
|
||||
if ($body !== null) $options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_THROW_ON_ERROR);
|
||||
curl_setopt_array($handle, $options);
|
||||
$ok = curl_exec($handle);$code = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($ok === false || $code !== 200) throw new RuntimeException('V4-Planempfang HTTP ' . $code . '; kein gueltiger Vorschauwert.');
|
||||
return NetzfahrplanV4Planpruefung::dekodieren($response);
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads independently of the old control loop. Never manufactures fresh timestamps. */
|
||||
private function netzfahrplanV4LokaleVorschauwerte(): array
|
||||
{
|
||||
$d = json_decode($this->GetNetzfahrplanV4Diagnose(), true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($d['operation'] ?? null)) throw new RuntimeException('Lokale Betriebsdaten ungueltig; Vorschau gesperrt.');
|
||||
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$actual = [];$now = time();
|
||||
foreach ($topology as $asset) {
|
||||
$id = (int) ($asset['LeistungVariableID'] ?? 0);
|
||||
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
|
||||
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
|
||||
$factor = $asset['Leistungsfaktor'] ?? null;
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v)
|
||||
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor == 0
|
||||
|| $before['VariableUpdated'] !== $after['VariableUpdated']
|
||||
|| $after['VariableUpdated'] > $now || $now - $after['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
|
||||
throw new RuntimeException('Batterie-Istleistung fehlt, ist veraltet oder inkonsistent.');
|
||||
}
|
||||
$actual[$asset['ID']] = (float) $v * $factor;
|
||||
}
|
||||
return [$d['operation'], $actual];
|
||||
}
|
||||
|
||||
public function GetNetzfahrplanV4Vorschau(): string
|
||||
{
|
||||
try {
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
throw new RuntimeException('V4-Empfang ausgeschaltet.');
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.');
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
||||
throw new RuntimeException('Noch kein gueltiger V4-Plan empfangen.');
|
||||
}
|
||||
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
|
||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
|
||||
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
|
||||
} catch (Throwable $error) {
|
||||
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)];
|
||||
}
|
||||
return json_encode(array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null],
|
||||
$out, ['canDispatch' => false, 'liveEnabled' => false]), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function aktualisiereNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$this->zeigeNetzfahrplanV4Vorschau(json_decode($this->GetNetzfahrplanV4Vorschau(), true, 64, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function zeigeNetzfahrplanV4Vorschau(array $value): void
|
||||
{
|
||||
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
|
||||
['canDispatch' => false, 'liveEnabled' => false]);
|
||||
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$html = '<h3>V4: NUR VORSCHAU - KEINE STELLFREIGABE</h3><table>';
|
||||
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
|
||||
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
|
||||
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
|
||||
$item = $value[$key] ?? '-';if (is_float($item)) $item = number_format($item, 1, '.', "'");
|
||||
$html .= '<tr><th>' . $escape($label) . '</th><td>' . $escape($item) . '</td></tr>';
|
||||
}
|
||||
$html .= '</table><p>Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.</p>';
|
||||
foreach ($value['reasons'] ?? [] as $reason) $html .= '<p>' . $escape($reason) . '</p>';
|
||||
$html .= '<p>Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.</p>';
|
||||
$this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
|
||||
$this->SetValue('NetzfahrplanV4Vorschau', $html);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ namespace Belevo\EnelixEMS;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/ManagerNetzfahrplanV4EmpfangTrait.php';
|
||||
|
||||
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
|
||||
trait ManagerNetzfahrplanV4Trait
|
||||
{
|
||||
use ManagerNetzfahrplanV4EmpfangTrait;
|
||||
private function registriereNetzfahrplanV4(): void
|
||||
{
|
||||
$this->registriereNetzfahrplanV4Empfang();
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
|
||||
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
|
||||
@@ -107,6 +111,8 @@ trait ManagerNetzfahrplanV4Trait
|
||||
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
||||
return;
|
||||
}
|
||||
// Read the preceding completed plan BEFORE sending a new operation/replan trigger.
|
||||
$this->empfangeNetzfahrplanV4();
|
||||
try {
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Pure observation-only validator. No IPS calls, transport, timers or actuator writes. */
|
||||
final class NetzfahrplanV4Planpruefung
|
||||
{
|
||||
public const MAX_RESPONSE_BYTES = 2000000;
|
||||
public const MAX_PLAN_AGE = 900;
|
||||
public const MAX_RECEIPT_AGE = 90;
|
||||
|
||||
private static function liste(array $value): bool
|
||||
{
|
||||
return $value === [] || array_keys($value) === range(0, count($value) - 1);
|
||||
}
|
||||
|
||||
public static function zeit($value): int
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) {
|
||||
throw new InvalidArgumentException('Zeitpunkt ohne eindeutige Zeitzone.');
|
||||
}
|
||||
$time = new DateTimeImmutable($value);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ($errors !== false && ($errors['warning_count'] || $errors['error_count'])) {
|
||||
throw new InvalidArgumentException('Ungueltiger Kalenderzeitpunkt.');
|
||||
}
|
||||
return $time->getTimestamp();
|
||||
}
|
||||
|
||||
private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float
|
||||
{
|
||||
if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) {
|
||||
throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
private static function uuid($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
|
||||
throw new InvalidArgumentException('Ungueltige UUID.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function dekodieren(string $raw): array
|
||||
{
|
||||
if (strlen($raw) > self::MAX_RESPONSE_BYTES) {
|
||||
throw new InvalidArgumentException('V4-Antwort zu gross.');
|
||||
}
|
||||
$result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($result) || self::liste($result)) {
|
||||
throw new InvalidArgumentException('V4-Antwort ist kein Objekt.');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Stable local planning policy. Volatile SOC/availability are checked separately. */
|
||||
public static function kontext(array $operation): array
|
||||
{
|
||||
$assets = [];
|
||||
foreach ($operation['batteries'] ?? [] as $b) {
|
||||
if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) {
|
||||
throw new InvalidArgumentException('Batteriezuordnung ungueltig.');
|
||||
}
|
||||
$assets[$b['id']] = [
|
||||
'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001),
|
||||
'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100),
|
||||
'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100),
|
||||
'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100),
|
||||
'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100),
|
||||
'gridCharging' => $b['gridCharging'] ?? null,
|
||||
];
|
||||
if (!is_bool($assets[$b['id']]['gridCharging'])) {
|
||||
throw new InvalidArgumentException('Netzladefreigabe fehlt.');
|
||||
}
|
||||
}
|
||||
if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) {
|
||||
throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.');
|
||||
}
|
||||
ksort($assets);
|
||||
$limits = $operation['limits'];
|
||||
$expected = ['importW', 'exportW', 'managerMonthLimitsW'];
|
||||
if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) {
|
||||
throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.');
|
||||
}
|
||||
foreach (['importW', 'exportW'] as $key) {
|
||||
if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0);
|
||||
}
|
||||
foreach ($limits['managerMonthLimitsW'] as $month => $limit) {
|
||||
if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) {
|
||||
throw new InvalidArgumentException('Ungueltige Monatsgrenze.');
|
||||
}
|
||||
$limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0);
|
||||
}
|
||||
ksort($limits['managerMonthLimitsW']);
|
||||
return ['batteries' => $assets, 'limits' => $limits];
|
||||
}
|
||||
|
||||
/** Authenticated endpoint must echo installation identity; old protocol is refused. */
|
||||
public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array
|
||||
{
|
||||
self::uuid($installation);
|
||||
if (($response['receiverProtocolVersion'] ?? null) !== 1
|
||||
|| ($response['installationId'] ?? null) !== $installation
|
||||
|| ($response['liveEnabled'] ?? null) !== false) {
|
||||
throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.');
|
||||
}
|
||||
if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) {
|
||||
throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.');
|
||||
}
|
||||
$serverAt = self::zeit($response['checkedAt'] ?? null);
|
||||
if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) {
|
||||
throw new InvalidArgumentException('Serverantwort nicht aktuell.');
|
||||
}
|
||||
if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) {
|
||||
throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.');
|
||||
}
|
||||
$settings = $response['settings'] ?? [];
|
||||
$p = $response['plan'] ?? [];
|
||||
if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0
|
||||
|| ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation
|
||||
|| ($p['configRevision'] ?? null) !== $settings['revision']
|
||||
|| ($settings['runMode'] ?? null) !== 'shadow'
|
||||
|| ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false
|
||||
|| ($p['executable'] ?? null) !== true
|
||||
|| !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
|
||||
throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.');
|
||||
}
|
||||
self::uuid($p['planId'] ?? null);
|
||||
if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId']
|
||||
|| !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
|
||||
throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.');
|
||||
}
|
||||
$families = array_column($response['families'] ?? [], 'key');
|
||||
if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true)
|
||||
|| (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) {
|
||||
throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.');
|
||||
}
|
||||
$generated = self::zeit($p['generatedAt'] ?? null);
|
||||
$start = self::zeit($p['validFrom'] ?? null);
|
||||
$until = self::zeit($p['validUntil'] ?? null);
|
||||
if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now
|
||||
|| $start !== $generated || $until - $start > 48 * 3600
|
||||
|| self::zeit($p['pricesKnownUntil'] ?? null) < $until) {
|
||||
throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.');
|
||||
}
|
||||
$opAt = self::zeit($operation['observedAt'] ?? null);
|
||||
if ($opAt > $now || $now - $opAt > 60) {
|
||||
throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.');
|
||||
}
|
||||
$context = self::kontext($operation);
|
||||
$pc = $p['controlContext'] ?? null;
|
||||
if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) {
|
||||
throw new InvalidArgumentException('Planungskontext fehlt.');
|
||||
}
|
||||
$policyAssets = [];
|
||||
foreach ($pc['batteries'] as $id => $policy) {
|
||||
if (!is_array($policy) || count($policy) !== 6) {
|
||||
throw new InvalidArgumentException('Unbekannter Batteriekontext.');
|
||||
}
|
||||
$policyAssets[] = ['id' => $id] + $policy;
|
||||
}
|
||||
// Normalize explicitly typed numeric values; strings/bools are not numbers.
|
||||
$plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]);
|
||||
if ($plannedContext != $context) {
|
||||
throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.');
|
||||
}
|
||||
if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null)
|
||||
|| $p['inputRefs']['operation'] === '') {
|
||||
throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.');
|
||||
}
|
||||
self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1);
|
||||
$points = $p['points'] ?? null;
|
||||
if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) {
|
||||
throw new InvalidArgumentException('Ungueltige Zeitschrittliste.');
|
||||
}
|
||||
$current = null;
|
||||
$last = $start;
|
||||
$ids = array_keys($context['batteries']);
|
||||
foreach ($points as $point) {
|
||||
$a = self::zeit($point['time'] ?? null);
|
||||
$b = self::zeit($point['validUntil'] ?? null);
|
||||
if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) {
|
||||
throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.');
|
||||
}
|
||||
$last = $b;
|
||||
$grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel');
|
||||
$bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel');
|
||||
$base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz');
|
||||
$curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0);
|
||||
if (abs($base + $bat + $curtail - $grid) > 1.0) {
|
||||
throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.');
|
||||
}
|
||||
if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) {
|
||||
throw new InvalidArgumentException('Unbekannte Planabsicht.');
|
||||
}
|
||||
$targets = $point['assetTargetsW'] ?? null;
|
||||
$socs = $point['socEndPercent'] ?? null;
|
||||
if (!is_array($targets) || !is_array($socs)) {
|
||||
throw new InvalidArgumentException('Batterieziele fehlen.');
|
||||
}
|
||||
$keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys);
|
||||
if ($keys !== $ids || $socKeys !== $ids) {
|
||||
throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.');
|
||||
}
|
||||
$sum = 0.0;
|
||||
foreach ($targets as $id => $w) {
|
||||
$sum += self::zahl($w, 'Einzelziel');
|
||||
self::zahl($socs[$id], 'Plan-SOC', 0, 100);
|
||||
}
|
||||
if (abs($sum - $bat) > 1.0) {
|
||||
throw new InvalidArgumentException('Summe der Batterieziele passt nicht.');
|
||||
}
|
||||
foreach (['importLimitW', 'exportLimitW'] as $limit) {
|
||||
if (!array_key_exists($limit, $point)) {
|
||||
throw new InvalidArgumentException('Plangrenze fehlt.');
|
||||
}
|
||||
if ($point[$limit] !== null) {
|
||||
$v = self::zahl($point[$limit], 'Plangrenze', 0);
|
||||
if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) {
|
||||
throw new InvalidArgumentException('Netzgrenze im Plan verletzt.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($a <= $now && $now < $b) {
|
||||
$current = $point;
|
||||
}
|
||||
}
|
||||
if ($last !== $until || $current === null) {
|
||||
throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.');
|
||||
}
|
||||
return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']];
|
||||
}
|
||||
|
||||
/** Proposed single-battery correction only. Never use this result as a dispatch permit. */
|
||||
public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array
|
||||
{
|
||||
$point = $checked['point'];$p = $checked['plan'];
|
||||
if (count($operation['batteries']) !== 1) {
|
||||
throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.');
|
||||
}
|
||||
$b = $operation['batteries'][0];$id = $b['id'];
|
||||
$age = $now - self::zeit($b['measuredAt'] ?? null);
|
||||
if ($age < 0 || $age > 60) {
|
||||
throw new InvalidArgumentException('Batteriemessung veraltet.');
|
||||
}
|
||||
$grid = self::zahl($operation['gridW'], 'Netzleistung');
|
||||
$battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung');
|
||||
$soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']);
|
||||
$remainingHours = (self::zeit($point['validUntil']) - $now) / 3600;
|
||||
if ($remainingHours <= 0) {
|
||||
throw new InvalidArgumentException('Planabschnitt abgelaufen.');
|
||||
}
|
||||
$eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001);
|
||||
$charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0),
|
||||
max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000);
|
||||
$discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0),
|
||||
max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000);
|
||||
$reasons = [];
|
||||
if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) {
|
||||
$discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve';
|
||||
}
|
||||
$residual = $grid - $battery;
|
||||
if ($b['gridCharging'] !== true) {
|
||||
$charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt';
|
||||
}
|
||||
$target = (float) $point['gridTargetW'];$limits = $operation['limits'];
|
||||
$import = $limits['importW'];$export = $limits['exportW'];
|
||||
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
|
||||
if (isset($limits['managerMonthLimitsW'][(string) $month])) {
|
||||
$monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0);
|
||||
$import = $import === null ? $monthly : min($import, $monthly);
|
||||
}
|
||||
if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0));
|
||||
if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0));
|
||||
$raw = $battery + $target - $grid;
|
||||
$wanted = $raw;
|
||||
if (abs($target) > 1.0) {
|
||||
$wanted = match ($point['intent']) {
|
||||
'gridCharge' => max(0., $wanted),
|
||||
'pvCharge' => max(0., min(max(0., -$residual), $wanted)),
|
||||
'discharge', 'export' => min(0., $wanted),
|
||||
'hold' => 0.,
|
||||
};
|
||||
}
|
||||
// Hard connection limits override economic intent, never BMS/availability.
|
||||
if ($import !== null) $wanted = min($wanted, $import - $residual);
|
||||
if ($export !== null) $wanted = max($wanted, -$export - $residual);
|
||||
$clipped = min($charge, max(-$discharge, $wanted));
|
||||
if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt';
|
||||
$expectedGrid = $residual + $clipped;
|
||||
$violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid);
|
||||
if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich';
|
||||
return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false,
|
||||
'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'],
|
||||
'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'],
|
||||
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
|
||||
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
|
||||
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
|
||||
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
|
||||
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user