feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
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.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user