feat(manager): integrate forecast controls and merge V4 development
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:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+227
View File
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
trait BatterieNetzfahrplanV4TestTrait
{
use BatterieNetzfahrplanV4RueckmeldungTrait;
private function registriereV4BatterieTest(): void
{
$this->registriereV4Rueckmeldung();
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4AktivtestErlaubt', false);
$this->RegisterPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt', false);
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
$this->RegisterAttributeString('NetzfahrplanV4TestStatus', '{"status":"disabled"}');
$this->RegisterTimer('NetzfahrplanV4TestWatchdog', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4TestWatchdog', 0);");
}
private function v4BatterieTestSitzung(): array
{
$s = json_decode($this->GetBuffer('V4BatteryTrialSession') ?: '{}', true);
return is_array($s) ? $s : [];
}
private function v4BatterieTestAktiv(): bool
{
return ($this->v4BatterieTestSitzung()['active'] ?? false) === true;
}
/** Local test arming by the bound manager; never called by ordinary forecast traffic. */
public function V4BatterieTestStarten(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieStartIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieStartIntern(string $json): void
{
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Batterietest lokal nicht erlaubt.');
$s = json_decode($json, true, 32, JSON_THROW_ON_ERROR);$now = time();
$activeTest = ($s['kind'] ?? null) === 'local_active_test_consent';
if (!is_array($s)
|| !in_array($s['kind'] ?? null, ['local_control_trial_consent', 'local_active_test_consent'], true)
|| ($s['batteryInstanceId'] ?? null) !== $this->InstanceID || $this->v4BatterieTestAktiv()) {
throw new RuntimeException('Batterie-Testanmeldung ungueltig oder bereits aktiv.');
}
NetzfahrplanV4Regeltest::uuid($s['sessionId'] ?? null);
if ($this->ReadAttributeString('NetzfahrplanV4LetzteTestsitzung') === $s['sessionId']) throw new RuntimeException('Testsitzung bereits beendet/verwendet.');
NetzfahrplanV4Regeltest::uuid($s['installationId'] ?? null);
$manager = NetzfahrplanV4Regeltest::integer($s['managerId'] ?? null, 'Manager', 1, 99999);
$managerPermission = $activeTest ? 'NetzfahrplanV4AktivtestErlaubt' : 'NetzfahrplanV4RegeltestErlaubt';
if (!in_array($manager, $this->zugeordneteManagerIDs(), true)
|| IPS_GetProperty($manager, $managerPermission) !== true) {
throw new RuntimeException('Manager nicht fuer Batterietest freigegeben/zugeordnet.');
}
$evidence = $this->ReadPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis');
$watchdogWaived = $activeTest
&& $this->ReadPropertyBoolean('NetzfahrplanV4WatchdogVerzichtErlaubt')
&& ($s['watchdogWaived'] ?? null) === true
&& ($s['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
if (!$watchdogWaived && (strlen($evidence) < 8 || $evidence !== ($s['actuatorWatchdogEvidenceId'] ?? null))) {
throw new RuntimeException('Externer Geraete-Watchdog muss separat nachgewiesen sein.');
}
$maxDuration = $activeTest
? NetzfahrplanV4Regeltest::MAX_ACTIVE_TEST_SECONDS
: NetzfahrplanV4Regeltest::MAX_TEST_SECONDS;
$end = NetzfahrplanV4Regeltest::integer($s['expiresAt'] ?? null, 'Testende', $now + 1, $now + $maxDuration);
$s['maxChargeW'] = NetzfahrplanV4Regeltest::number($s['maxChargeW'] ?? null, 'Testladen', 1, 1e9);
$s['maxDischargeW'] = NetzfahrplanV4Regeltest::number($s['maxDischargeW'] ?? null, 'Testentladen', 1, 1e9);
if (!is_string($s['assetId'] ?? null) || $s['assetId'] === '') throw new RuntimeException('Batterie-ID fehlt.');
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
}
$feedback = $this->v4BatterieRueckmeldung();
$now = time();
if ($now >= $end) throw new RuntimeException('Testsitzung waehrend Geraeteabruf abgelaufen.');
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
$s['commandMonotonicDeadline'] = hrtime(true) / 1e9 + 10;
$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
// Old ordinary values may not resume after the test without a NEW manager message.
$this->setzeZustand('SollwertGueltig', false);
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 1000);
$this->WriteAttributeString('NetzfahrplanV4TestStatus', '{"status":"armed_waiting_command","physicalResponseVerified":false}');
}
public function V4BatterieTestBefehl(string $json): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->v4BatterieBefehlIntern($json); }
finally { IPS_SemaphoreLeave($lock); }
}
private function v4BatterieBefehlIntern(string $json): void
{
$s = $this->v4BatterieTestSitzung();$now = time();$mono = hrtime(true) / 1e9;
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Batterietest gesperrt.');
}
$c = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
NetzfahrplanV4Regeltest::pruefeBatteriebefehl($c, $s, $now, $mono);
$s['lastSequence'] = $c['sequence'];$s['lastWallClockAt'] = $now;
$s['commandMonotonicDeadline'] = $mono + min(10, $c['expiresAt'] - $now);
$this->SetBuffer('V4BatteryTrialCommand', json_encode($c, JSON_THROW_ON_ERROR));
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
$this->aktualisiereIntern(true);
if (!$this->v4BatterieTestAktiv() || $this->ReadAttributeString('Registerfehler') !== '') {
$state = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$detail = $this->ReadAttributeString('Registerfehler') ?: ($state['reason'] ?? '');
throw new RuntimeException('Batterie konnte Testbefehl nicht sicher uebernehmen: ' . substr($detail, 0, 300));
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode([
'status' => 'controller_command_accepted', 'sessionId' => $s['sessionId'],
'sourceShadowPlanId' => $c['sourceShadowPlanId'], 'sequence' => $c['sequence'],
'commandExpiresAt' => $c['expiresAt'], 'actualRequestedW' => $this->ReadAttributeInteger('LetzterSollwert'),
'physicalResponseVerified' => false], JSON_THROW_ON_ERROR));
} catch (Throwable $e) {
$this->beendeV4BatterieTest('Befehl abgelehnt: ' . substr($e->getMessage(), 0, 500), true);
throw $e;
}
}
/** Explicit local abort or ApplyChanges: revoke before any register action. */
private function beendeV4BatterieTest(string $reason, bool $writeNow): void
{
$oldStatus = json_decode($this->ReadAttributeString('NetzfahrplanV4TestStatus'), true);
$active = $this->v4BatterieTestAktiv() || in_array($oldStatus['status'] ?? '', ['controller_command_accepted', 'armed_waiting_command', 'stop_pending', 'stop_failed'], true);
$this->SetBuffer('V4BatteryTrialSession', '{}');$this->SetBuffer('V4BatteryTrialCommand', '{}');
$this->SetTimerInterval('NetzfahrplanV4TestWatchdog', 0);
if (!$active) return;
$this->setzeZustand('SollwertGueltig', false);$this->setzeZustand('Sollleistung', 0);
$report = ['status' => 'stop_pending', 'reason' => $reason, 'physicalResponseVerified' => false];
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
if ($writeNow) {
try { $this->schreibeRegister(0, true);$report['status'] = 'stopped'; }
catch (Throwable $e) {
$report['status'] = 'stop_failed';
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
throw $e;
}
$this->WriteAttributeString('NetzfahrplanV4TestStatus', json_encode($report, JSON_THROW_ON_ERROR));
}
}
public function V4BatterieTestStoppen(): void
{
$lock = 'ENELIX.V4.Battery.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 1000)) throw new RuntimeException('Batterie beschaeftigt.');
try { $this->beendeV4BatterieTest('Lokaler Testabbruch', true); }
finally { IPS_SemaphoreLeave($lock); }
}
public function GetV4BatterieTestStatus(): string
{
return $this->ReadAttributeString('NetzfahrplanV4TestStatus');
}
/** Called inside the existing battery update, immediately before writing registers. */
private function v4BatterieTestZiel(array $measurements, bool $available, int $now): ?int
{
if (!$this->v4BatterieTestAktiv()) return null;
$s = $this->v4BatterieTestSitzung();
try {
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')
|| (($s['kind'] ?? null) === 'local_active_test_consent'
&& !$this->ReadPropertyBoolean('NetzfahrplanV4AktivtestErlaubt'))) {
throw new RuntimeException('Lokale Testfreigabe entzogen.');
}
$feedback = $this->v4BatterieRueckmeldung();
// The confirmed device read may cross a wall-clock second. All freshness,
// watchdog and switching decisions below use the post-read clocks.
$now = time();$mono = hrtime(true) / 1e9;
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
if (!$c) {
if ($mono >= $s['commandMonotonicDeadline']) throw new RuntimeException('Kein Startbefehl innerhalb Watchdog.');
return 0;
}
$w = NetzfahrplanV4Regeltest::batterieZiel($c, $s, $measurements, [
'available' => $available, 'reserve' => $this->ReadPropertyFloat('ReserveLadezustand'),
'minimum' => $this->ReadPropertyFloat('MindestLadezustand'),
'blocked' => (bool) $this->leseZustand('HystereseAktiv'),
'rechargeLimitW' => $this->ReadPropertyBoolean('NachladenMitMaximalleistung') ? null : $this->ReadPropertyInteger('MaximaleNachladeleistung'),
], $now, $mono);
// Respect switching lock for increases/reversals; safety reductions and zero are immediate.
$last = $this->ReadAttributeInteger('LetzterSollwert');
if ($now < $this->ReadAttributeInteger('AenderungFreigabeAb') && $w !== $last) {
if ($last * $w < 0) $w = 0;
elseif (abs($w) > abs($last)) $w = $last;
}
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
}
$s['lastWallClockAt'] = $now;
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
return $w;
} catch (Throwable $e) {
$this->beendeV4BatterieTest($e->getMessage(), false);
return 0; // revoke stale value even if ordinary cached setpoint existed
}
}
}