Files
Enelix-EMS/libs/ManagerNetzfahrplanV4TestTrait.php
T
dh 9fbc52ee47
Tests / test (push) Failing after 49s
feat(manager): integrate forecast controls and merge V4 development
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
2026-10-06 14:22:16 +00:00

224 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Local consent + server commissioning authority. NEVER executes a preview alone. */
trait ManagerNetzfahrplanV4TestTrait
{
private function registriereV4ManagerTest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterAttributeString('NetzfahrplanV4RegeltestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestTick', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'Regeln', true);");
}
private function v4ManagerTestSession(): array
{
$value = json_decode($this->GetBuffer('V4ManagerTrialSession') ?: '{}', true);
return is_array($value) ? $value : [];
}
private function v4ManagerTestBedingungen(): void
{
if (IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102
|| !$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|| $this->ReadPropertyBoolean('NetzfahrplanAktiv') || !$this->GetValue('Aktiv')
|| !$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
throw new RuntimeException('Keine lokale Testberechtigung oder konkurrierender Fahrplan.');
}
}
/** Explicit kernel-local user action only. No network request can call this via the portal. */
public function V4ManagerTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt.');
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if ($this->v4ManagerTestSession()) throw new RuntimeException('Testsitzung bereits aktiv; nicht verlaengern.');
$request = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['confirmation'] ?? null) !== 'START_BOUNDED_V4_CONTROL_TRIAL') {
throw new RuntimeException('Ausdrueckliche lokale Testbestaetigung erforderlich.');
}
$session = NetzfahrplanV4Regeltest::uuid($request['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $session) throw new RuntimeException('Testsitzung bereits benutzt; neue Freigabe erforderlich.');
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
$a = $envelope['response']['controlledTrial'] ?? null;
if (!is_array($a) || ($a['sessionId'] ?? null) !== $session) throw new RuntimeException('Keine passende separate Serverfreigabe empfangen.');
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$batteryID = (int) ($a['batteryInstanceId'] ?? 0);
if (!in_array($batteryID, $this->aktiveVerbraucherIDs(), true)
|| !IPS_InstanceExists($batteryID)
|| IPS_GetInstance($batteryID)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
throw new RuntimeException('Testbatterie ist keine aktive zugeordnete Batterieinstanz.');
}
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (count($assets) !== 1 || $assets[0]['ID'] !== ($a['assetId'] ?? null)
|| $assets[0]['SOCVariableID'] !== IPS_GetProperty($batteryID, 'LadezustandVariableID')
|| $assets[0]['LeistungVariableID'] !== IPS_GetProperty($batteryID, 'IstleistungVariableID')) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
$now = time();$until = min(NetzfahrplanV4Planpruefung::zeit($a['expiresAt']),
$now + NetzfahrplanV4Regeltest::integer($request['durationSeconds'] ?? null, 'Lokale Testdauer', 30, 1800));
$s = ['kind' => 'local_control_trial_consent', 'enabled' => true, 'legacyEnabled' => false,
'sessionId' => $session, 'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID, 'batteryInstanceId' => $batteryID, 'assetId' => $a['assetId'],
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 1e9),
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 1e9),
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'],
'sequence' => 1, 'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
NetzfahrplanV4Regeltest::befehl($envelope['response'], $op, $actual, $s, $now, $envelope['receivedAt']);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $session);
$armedBattery = $batteryID;
$this->v4ManagerBatterieAktion($batteryID, 'NetzfahrplanV4TestStart', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->SetTimerInterval('NetzfahrplanV4TestTick', 2000);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'armed_local_trial', 'sessionId' => $session, 'expiresAt' => $until,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) $this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
throw $e;
} finally { IPS_SemaphoreLeave($lock); }
}
/** Revocation precedes I/O; if the device call fails its own ten-second lease still expires. */
private function v4ManagerTestAbbrechen(string $reason): void
{
$s = $this->v4ManagerTestSession();
$this->SetBuffer('V4ManagerTrialSession', '{}');$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
$this->SetValue('NetzfahrplanV4Aktivtest', false);
if (!$s) return;
$result = ['status' => 'stopped_fallback_pending', 'reason' => $reason,
'sessionId' => $s['sessionId'], 'physicalResponseVerified' => false];
// Reissue the next ordinary target even if numerically equal to a previously cached value.
$this->WriteAttributeString('LetzteSollwerte', '{}');
try {
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestStop', 0);
$result['controllerStopAccepted'] = true;
} catch (Throwable $e) {
$result['controllerStopAccepted'] = false;$result['reason'] .= '; Geraete-Abbruch fehlgeschlagen, Watchdog pruefen.';
}
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode($result, JSON_THROW_ON_ERROR));
}
public function V4ManagerTestStoppen(): void
{
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Regelung beschaeftigt; Stop erneut anfordern.');
try { $this->v4ManagerTestAbbrechen('Lokaler Testabbruch'); }
finally { IPS_SemaphoreLeave($lock); }
$this->regeln(true); // fresh ordinary command; never restore a cached pre-trial target
}
public function GetV4ManagerTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4RegeltestStatus');
}
private function v4ManagerBatterieAktion(int $id, string $ident, $value): void
{
if (IPS_RequestAction($id, $ident, $value) !== true) {
throw new RuntimeException('Batterieaktion nicht bestaetigt: ' . $ident);
}
}
/** Called under the existing regulator's serialization, after allocation but before writes. */
private function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $synchronized): void
{
$s = $this->v4ManagerTestSession();
if (!$s) return;
try {
$this->v4ManagerTestBedingungen();$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
// The ordinary commissioning trial still requires a fully synchronized consumer set.
// In the explicitly approved 48h active test, V4 is the sole actuator owner: stale or
// unrelated consumers receive no new Manager target and their physical effect is seen
// through the verified grid feedback on the next V4 cycle.
if ((!$activeTest && !$synchronized) || $now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException($activeTest ? 'Testende erreicht.' : 'Testende oder Verbraucher nicht synchronisiert.');
}
[$op, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
// Confirmed device reads can cross a wall-clock second. Validate their checkedAt
// against a post-read clock, never against the timestamp captured before I/O.
$now = time();
if ($now >= $s['expiresAt'] || hrtime(true) / 1e9 >= $s['monotonicDeadline']) {
throw new RuntimeException('Testende erreicht.');
}
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
// Other consumers stay under the existing controller. Compensate their predicted
// change once; no double-counting of the battery's already flowing actual power.
// The explicitly approved active test owns its bound battery directly. It must not
// depend on the legacy allocation list as long as the confirmed V4 feedback still
// proves the same battery binding and is usable for the trial.
$otherDelta = 0.0;$found = false;
if ($activeTest) {
$feedback = $op['localFeedback'][$s['assetId']] ?? null;
$found = is_array($feedback)
&& ($feedback['batteryInstanceId'] ?? null) === $s['batteryInstanceId']
&& ($feedback['usableForTrial'] ?? false) === true
&& ($feedback['deviceReadConfirmed'] ?? false) === true;
}
foreach ($consumers as $consumer) {
$id = $consumer['InstanzID'];
if ($id === $s['batteryInstanceId']) { $found = true;continue; }
if (!array_key_exists($id, $allocation['Sollwerte'])) continue;
if ($activeTest) {
unset($allocation['Sollwerte'][$id]);
continue;
}
$d = $consumer['Daten'];
if (($d['Leistungsquelle'] ?? null) !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) throw new RuntimeException('Andere Verbraucherleistung nicht gemessen.');
$otherDelta += NetzfahrplanV4Regeltest::number($allocation['Sollwerte'][$id], 'Anderer Sollwert', -1e9, 1e9)
- NetzfahrplanV4Regeltest::number($d['Istleistung_W'] ?? null, 'Andere Istleistung', -1e9, 1e9);
}
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
$op['gridW'] += $otherDelta;
$op['localPredictedOtherDeltaW']=$otherDelta;
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
}
$response = $envelope['response'];
if (($s['kind'] ?? null) === 'local_active_test_consent') {
if (!$this->GetValue('NetzfahrplanV4Aktivtest')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtestfreigabe entzogen.');
}
$response = $this->v4AktivtestAntwort($response, $s, $now);
}
$command = NetzfahrplanV4Regeltest::befehl($response, $op, $actual, $s, $now, $envelope['receivedAt']);
// A controller-side refusal stops the session; never downgrade it to an ordinary target.
$this->v4ManagerBatterieAktion($s['batteryInstanceId'], 'NetzfahrplanV4TestCommand', json_encode($command, JSON_THROW_ON_ERROR));
$s['sequence']++;$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4ManagerTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
unset($allocation['Sollwerte'][$s['batteryInstanceId']]); // one owner of this actuator
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'controller_command_sent', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $command['sourceShadowPlanId'], 'command' => $command,
'otherConsumerPredictedDeltaW' => $otherDelta, 'otherConsumersFrozen' => $activeTest,
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->v4ManagerTestAbbrechen($e->getMessage());
// Stop writes zero and next NEW ordinary allocation runs on this same pass.
}
}
}