diff --git a/docs/testing/Netzfahrplan-V4-Schatten.md b/docs/testing/Netzfahrplan-V4-Schatten.md index 2770226..19a6a4c 100644 --- a/docs/testing/Netzfahrplan-V4-Schatten.md +++ b/docs/testing/Netzfahrplan-V4-Schatten.md @@ -27,12 +27,22 @@ hinzugefuegt. SDL-/Grundlastbereinigung bleibt eine separate, noch offene Aufgab BMS-Grenzen werden mit Topologieleistungen begrenzt. Normale Betriebsreserve und technisches Minimum werden nicht verwechselt: die hoehere Grenze gilt. -Bei aktueller Entladehysterese wird die Entladeleistung konservativ auf 0 -begrenzt. Eine spaetere Freigabe innerhalb der 48h wird noch nicht modelliert. -Das ist sichtbar diagnostiziert und kann den Schattenplan wirtschaftlich -beschraenken; eine zeitabhaengige Hysterese-Modellierung bleibt offen. -SOC unterhalb der Reserve wird nicht kuenstlich angehoben: Diagnosefehler, -kein angeblich gueltiger Plan. Recovery-/Nachladungsmodell bleibt offen. +Der erweiterte Sender uebermittelt Entladesperre und Wiederfreigabe-SOC separat +von der maximalen Entladeleistung. Der neue Python-Rechenkern darf die Sperre +im Zukunftsplan erst nach vorherigem Laden bis zur Wiederfreigabe aufheben. +SOC unterhalb der Betriebsreserve, aber oberhalb des technischen Minimums, +bleibt unveraendert erhalten: Es ist Wiederaufladung statt erfundener Energie +vorgesehen. Unterhalb des technischen Minimums wird kein normaler Plan erzeugt. + +Zusaetzlich wird meterObservation mit frischen Empfangswerten der Netzleistung +und T1/T2-Summe uebermittelt. Dessen laufende Viertelstundenintegration ist eine +explizite Planungsschaetzung, KEIN verifizierter Abrechnungsnachweis. Verwendung +von Schaetzungen muss im V4-Dienst ausdruecklich aktiviert werden. Historische +Peakannahmen werden getrennt von verifizierten Maxima gespeichert. + +Diese Protokollerweiterung benoetigt zuerst den passenden Backendstand. Der +bisherige laufende V4-Container und die Testanlage sind noch nicht aktualisiert. +Offline-Szenarien und Quelltextpruefungen ersetzen keinen Symcon-Laufzeittest. ## Diagnose ohne Netzwerk oder Stellbefehl diff --git a/libs/ManagerNetzfahrplanV4Trait.php b/libs/ManagerNetzfahrplanV4Trait.php index bc187a3..c6e7412 100644 --- a/libs/ManagerNetzfahrplanV4Trait.php +++ b/libs/ManagerNetzfahrplanV4Trait.php @@ -33,6 +33,7 @@ trait ManagerNetzfahrplanV4Trait foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) { $manager[$key] = $this->ReadPropertyFloat($key); } + $manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified'; $manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv'); $manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt'); $manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR); @@ -50,7 +51,7 @@ trait ManagerNetzfahrplanV4Trait } $c = ['InstanzID' => $id]; foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID', - 'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) { + 'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'LadezustandHysterese', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) { $c[$key] = IPS_GetProperty($id, $key); } $entry = $cache[(string) $id] ?? []; diff --git a/libs/NetzfahrplanV4Betriebsdaten.php b/libs/NetzfahrplanV4Betriebsdaten.php index 1502536..35495da 100644 --- a/libs/NetzfahrplanV4Betriebsdaten.php +++ b/libs/NetzfahrplanV4Betriebsdaten.php @@ -161,7 +161,8 @@ final class NetzfahrplanV4Betriebsdaten 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) { + $physicalMin = self::number($c['MindestLadezustand'], 'Technisches Minimum', 0, $min); + if ($socValue < $physicalMin || $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)) { @@ -173,14 +174,20 @@ final class NetzfahrplanV4Betriebsdaten $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.'; + $rearm = min($max, $min + self::number($c['LadezustandHysterese'] ?? 0.0, 'Entladehysterese', 0, 100)); + $blocked = !empty($c['HystereseAktiv']) || $socValue <= $min; + if ($blocked) { + $warnings[] = $id . ': Entladesperre im Modell aktiv bis zur Wiederfreigabe nach vorheriger Ladung.'; + } + if ($socValue < $min) { + $warnings[] = $id . ': unter Betriebsreserve; realen SOC behalten und nur zulassige Wiederaufladung planen.'; } $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]; + 'gridCharging' => ($manager['NetzladenErlaubt'] ?? false) === true, + 'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true, + 'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm]; } if ($result === []) { throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.'); @@ -190,6 +197,22 @@ final class NetzfahrplanV4Betriebsdaten 'meteringBoundary' => 'common_pcc', 'batteries' => $result, 'limits' => ['importW' => $import, 'exportW' => $export, 'managerMonthLimitsW' => (object) $months], 'measuredPeaks' => $peaks, 'quarterPast' => $past]; + // A fresh acquisition sample is an operational estimate. It does NOT + // claim a calibrated billing-period boundary or full historical coverage. + $policy = [ + 'adapterVersion' => 'v4-native-estimated-meter-1', + 'sourcePlan' => $manager['V4ControlPlanSource'] ?? 'legacy-unspecified', + 'mode' => $mode, 'importW' => $import, 'exportW' => $export, + 'months' => $months, + 'batteryPolicy' => array_map(static function (array $b): array { + return array_intersect_key($b, array_flip(['id', 'capacityKwh', 'minSocPercent', 'maxSocPercent', 'physicalMinSocPercent', 'rearmSocPercent'])); + }, $result), + ]; + $payload['meterObservation'] = [ + 'meterId' => $meter['meterId'], 'sampleAt' => gmdate('c', $now), + 'powerW' => $gridW, 'totalImportKwh' => $meter['totalKwh'], + 'controlPolicyId' => hash('sha256', json_encode($policy, JSON_THROW_ON_ERROR)), + ]; $payload['eventId'] = 'symcon-operation-' . hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); return ['status' => $warnings === [] ? 'ready_shadow' : 'incomplete_shadow', 'warnings' => $warnings, 'bezugszaehler' => $meter, 'operation' => $payload]; } diff --git a/tests/fixtures/NativeV4Scenarios.php b/tests/fixtures/NativeV4Scenarios.php index e85d817..0fa58a0 100644 --- a/tests/fixtures/NativeV4Scenarios.php +++ b/tests/fixtures/NativeV4Scenarios.php @@ -86,9 +86,10 @@ final class NativeV4Scenarios $cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void { $c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c)); }; - $cases['soc_not_lifted_to_minimum'] = static function () use ($build, $read, $fail): void { + $cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void { $r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; }; - $fail(static fn() => $build(null, null, null, $r)); + $b = $build(null, null, null, $r)['operation']['batteries'][0]; + $check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true); }; $cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void { $c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0]; @@ -96,7 +97,7 @@ final class NativeV4Scenarios }; $cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void { $c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c); - $check($x['operation']['batteries'][0]['maxDischargeW'] === 0.0 && count($x['warnings']) > 1); + $check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1); }; $cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void { $check($build()['operation']['batteries'][0]['gridCharging'] === false);