Files
Enelix-EMS/libs/ManagerNetzfahrplanV4AktivtestTrait.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

226 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
/** Locally approved, time-boxed operation of validated V4 shadow plans on a test plant. */
trait ManagerNetzfahrplanV4AktivtestTrait
{
private const V4_AKTIVTEST_DAUER_SEKUNDEN = 172800;
private const V4_AKTIVTEST_WATCHDOG_VERZICHT = 'explicit_test_plant_watchdog_waiver';
private function registriereV4ManagerAktivtest(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestBeginn', 0);
$this->RegisterAttributeInteger('NetzfahrplanV4AktivtestEnde', 0);
$this->RegisterVariableBoolean(
'NetzfahrplanV4Aktivtest',
'V4 Prognosemanager aktiv (Testbetrieb)',
'~Switch',
952
);
$this->EnableAction('NetzfahrplanV4Aktivtest');
}
public function V4ManagerAktivtestSchalten(bool $aktiv): void
{
if (!$aktiv) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->V4ManagerTestStoppen();
return;
}
if ($this->GetValue('NetzfahrplanV4Aktivtest') && $this->v4ManagerTestSession()) {
return;
}
$lock = 'ENELIX.V4.Control.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) {
throw new RuntimeException('Regelung beschaeftigt.');
}
$armedBattery = null;
try {
$this->v4ManagerTestBedingungen();
if (!$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt')
|| !$this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')) {
throw new RuntimeException('Aktivtest oder Watchdog-Verzicht ist lokal nicht freigegeben.');
}
if ($this->v4ManagerTestSession()) {
throw new RuntimeException('Eine V4-Regelsitzung ist bereits aktiv.');
}
$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('Kein gueltiger aktueller V4-Plan empfangen.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 32, JSON_THROW_ON_ERROR);
if (!is_array($assets) || count($assets) !== 1 || !is_string($assets[0]['ID'] ?? null)) {
throw new RuntimeException('Aktivtest braucht genau eine eindeutig konfigurierte Batterie.');
}
$asset = $assets[0];
$batteryID = $this->v4AktivtestBatterieinstanz($asset);
$now = time();
$start = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestBeginn');
$until = $this->ReadAttributeInteger('NetzfahrplanV4AktivtestEnde');
if ($start === 0 && $until === 0) {
$start = $now;
$until = $now + self::V4_AKTIVTEST_DAUER_SEKUNDEN;
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestBeginn', $start);
$this->WriteAttributeInteger('NetzfahrplanV4AktivtestEnde', $until);
}
if ($start <= 0 || $until <= $now
|| $until - $start > self::V4_AKTIVTEST_DAUER_SEKUNDEN) {
throw new RuntimeException('Die einmalige 48-Stunden-Aktivtestfreigabe ist abgelaufen.');
}
[$maxChargeW, $maxDischargeW] = $this->v4AktivtestLeistungsgrenzen($operation, $asset['ID']);
$session = $this->v4AktivtestUuid();
$s = [
'kind' => 'local_active_test_consent',
'enabled' => true,
'legacyEnabled' => false,
'sessionId' => $session,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'managerId' => $this->InstanceID,
'batteryInstanceId' => $batteryID,
'assetId' => $asset['ID'],
'startedAt' => $start,
'expiresAt' => $until,
'maxChargeW' => $maxChargeW,
'maxDischargeW' => $maxDischargeW,
'watchdogWaived' => true,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'feedbackConfigHash' => $operation['localFeedback'][$asset['ID']]['configHash'] ?? '',
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($operation)['limits'],
'sequence' => 1,
'lastWallClockAt' => $now,
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now,
];
$response = $this->v4AktivtestAntwort($envelope['response'], $s, $now);
NetzfahrplanV4Regeltest::befehl(
$response,
$operation,
$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->SetValue('NetzfahrplanV4Aktivtest', true);
$this->WriteAttributeString('NetzfahrplanV4RegeltestStatus', json_encode([
'status' => 'active_test_started',
'sessionId' => $session,
'expiresAt' => $until,
'watchdogWaivedForTestPlant' => true,
'physicalResponseVerified' => false,
], JSON_THROW_ON_ERROR));
} catch (Throwable $error) {
$this->SetValue('NetzfahrplanV4Aktivtest', false);
$this->SetBuffer('V4ManagerTrialSession', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestTick', 0);
if ($armedBattery !== null) {
$this->v4ManagerBatterieAktion($armedBattery, 'NetzfahrplanV4TestStop', 0);
}
throw $error;
} finally {
IPS_SemaphoreLeave($lock);
}
}
private function v4AktivtestLeistungsgrenzen(array $operation, string $assetId): array
{
foreach ($operation['batteries'] ?? [] as $battery) {
if (($battery['id'] ?? null) !== $assetId) {
continue;
}
return [
NetzfahrplanV4Regeltest::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9),
NetzfahrplanV4Regeltest::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9),
];
}
throw new RuntimeException('Dynamische Leistungsgrenzen der Testbatterie fehlen.');
}
private function v4AktivtestBatterieinstanz(array $asset): int
{
$matches = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
continue;
}
if (($asset['SOCVariableID'] ?? null) === IPS_GetProperty($id, 'LadezustandVariableID')
&& ($asset['LeistungVariableID'] ?? null) === IPS_GetProperty($id, 'IstleistungVariableID')) {
$matches[] = $id;
}
}
if (count($matches) !== 1) {
throw new RuntimeException('Physische Batteriezuordnung ist nicht eindeutig.');
}
return $matches[0];
}
private function v4AktivtestAntwort(array $response, array $session, int $now): array
{
$plan = $response['plan'] ?? null;
if (!is_array($plan)) {
throw new RuntimeException('V4-Plan fehlt.');
}
$validUntil = min($now + 90, (int) $session['expiresAt']);
$quality = $plan['inputQuality'] ?? [];
$response['controlledTrial'] = [
'version' => 1,
'kind' => 'controlled_trial_authority',
'sourcePlanRemainsShadow' => true,
'installationId' => $session['installationId'],
'sessionId' => $session['sessionId'],
'managerId' => $session['managerId'],
'batteryInstanceId' => $session['batteryInstanceId'],
'assetId' => $session['assetId'],
'sourceShadowPlanId' => $plan['planId'] ?? null,
'revision' => $plan['configRevision'] ?? null,
'family' => $plan['sourceFamily'] ?? null,
'issuedAt' => gmdate('c', (int) $session['startedAt']),
'expiresAt' => gmdate('c', (int) $session['expiresAt']),
'checkedAt' => gmdate('c', $now),
'validUntil' => gmdate('c', $validUntil),
'maxChargeW' => $session['maxChargeW'],
'maxDischargeW' => $session['maxDischargeW'],
'accountingEvidenceId' => $quality['accountingEvidenceId'] ?? null,
'actuatorWatchdogEvidenceId' => self::V4_AKTIVTEST_WATCHDOG_VERZICHT,
'acceptEstimatedPeak' => true,
'controlContext' => $plan['controlContext'] ?? null,
];
return $response;
}
private function v4AktivtestUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4)
. '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
}
}