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,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
|
||||
require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
|
||||
|
||||
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
|
||||
trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
{
|
||||
private function registriereV4Rueckmeldung(): void
|
||||
{
|
||||
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
|
||||
$this->RegisterTimer('NetzfahrplanV4RueckmeldungAktualisieren', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4RueckmeldungAktualisieren', 0);");
|
||||
}
|
||||
|
||||
private function konfiguriereV4Rueckmeldung(): void
|
||||
{
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
|
||||
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
|
||||
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
|
||||
}
|
||||
|
||||
/** No transport I/O under the battery control lock. Original sample times never move. */
|
||||
private function v4BatterieRueckmeldung(): array
|
||||
{
|
||||
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
$sample = json_decode($this->GetBuffer('V4ConfirmedFeedback'), true);
|
||||
$mono = (float) $this->GetBuffer('V4ConfirmedFeedbackMono');
|
||||
$now = time();$age = hrtime(true) / 1e9 - $mono;
|
||||
if (!is_array($sample) || !is_int($sample['checkedAt'] ?? null)
|
||||
|| $sample['checkedAt'] > $now || $now - $sample['checkedAt'] > 2
|
||||
|| $age < 0 || $age > 2
|
||||
|| $this->GetBuffer('V4ConfirmedFeedbackConfig') !== hash('sha256', $json)) {
|
||||
throw new \RuntimeException('feedback_confirmed_cache_stale_or_invalid');
|
||||
}
|
||||
$config = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
|
||||
if (($config['batteryInstanceId'] ?? null) !== $this->InstanceID
|
||||
|| !in_array($config['managerId'] ?? 0, $this->zugeordneteManagerIDs(), true)) {
|
||||
throw new \RuntimeException('feedback_cache_binding_invalid');
|
||||
}
|
||||
foreach ($config['sources'] as $source) {
|
||||
if (!IPS_VariableExists($source['variableId'])) throw new \RuntimeException('feedback_variable_missing');
|
||||
$object = IPS_GetObject($source['variableId']);
|
||||
if ($object['ParentID'] !== $source['parentId'] || $object['ObjectIdent'] !== $source['ident']) {
|
||||
throw new \RuntimeException('feedback_cache_source_changed');
|
||||
}
|
||||
}
|
||||
return $sample;
|
||||
}
|
||||
|
||||
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
|
||||
private function aktualisiereV4Rueckmeldung(): void
|
||||
{
|
||||
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
|
||||
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
|
||||
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
|
||||
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
|
||||
|
||||
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock,0)) return;
|
||||
try {
|
||||
$reader=static function(int $id):array {
|
||||
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
|
||||
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
|
||||
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],
|
||||
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
|
||||
};
|
||||
$result=null;
|
||||
for ($attempt=1;$attempt<=3;++$attempt) {
|
||||
try {
|
||||
$result=NetzfahrplanV4Geraeteabruf::snapshot(
|
||||
$c,
|
||||
static function(int $id):string {
|
||||
if (!IPS_InstanceExists($id)) throw new \RuntimeException('feedback_parent_instance_missing');
|
||||
return (string)(IPS_GetInstance($id)['ModuleInfo']['ModuleID']??'');
|
||||
},
|
||||
static function(int $id,string $module):bool {
|
||||
if ($module===NetzfahrplanV4Geraeteabruf::MBUS_DEVICE) {
|
||||
if (!function_exists('MBUS_UpdateValues') && !function_exists(__NAMESPACE__.'\\MBUS_UpdateValues')) throw new \RuntimeException('feedback_mbus_refresh_unavailable');
|
||||
return MBUS_UpdateValues($id)===true;
|
||||
}
|
||||
if (in_array($module,[NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS],true)) {
|
||||
if (!function_exists('ModBus_RequestRead') && !function_exists(__NAMESPACE__.'\\ModBus_RequestRead')) throw new \RuntimeException('feedback_modbus_refresh_unavailable');
|
||||
return ModBus_RequestRead($id)===true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
$reader,
|
||||
static fn():int=>time(),
|
||||
static function(int $microseconds):void { usleep($microseconds); }
|
||||
);
|
||||
break;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
if (!str_starts_with($e->getMessage(),'feedback_source_changed_after_confirmed_read:') || $attempt>=3) throw $e;
|
||||
usleep(150000);
|
||||
}
|
||||
}
|
||||
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
|
||||
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
|
||||
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
|
||||
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
|
||||
} catch (\Throwable $error) {
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
throw $error;
|
||||
} finally {
|
||||
IPS_SemaphoreLeave($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read only. A returned current value is NOT actuator permission. */
|
||||
public function GetV4BatterieRueckmeldung(): string
|
||||
{
|
||||
try {
|
||||
try { $r=$this->v4BatterieRueckmeldung(); }
|
||||
catch (\Throwable) {
|
||||
$this->aktualisiereV4Rueckmeldung();
|
||||
$r=$this->v4BatterieRueckmeldung();
|
||||
}
|
||||
}
|
||||
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
|
||||
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false,'deviceReadConfirmed'=>false]; }
|
||||
return json_encode($r,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Datenarchiv.php';
|
||||
|
||||
/** Application measurement acquisition + acknowledged durable delivery. No device writes. */
|
||||
trait ManagerNetzfahrplanV4DatenTrait
|
||||
{
|
||||
private function registriereNetzfahrplanV4Daten(): void
|
||||
{
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4MessdatenAktiv', false);
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4ArchivAktiv', false);
|
||||
$this->RegisterPropertyString('NetzfahrplanV4Messkonfiguration', '{}');
|
||||
$this->RegisterPropertyString('NetzfahrplanV4Datensatz', '');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4DatenCursor', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteDatenquittung', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4ArchivTag', '');
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersion', 0);
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||
$this->RegisterVariableString('NetzfahrplanV4Datenstatus', 'V4 Messdaten und Modellversorgung', '', 199);
|
||||
$this->RegisterTimer('NetzfahrplanV4DatenErfassen', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4DatenErfassen', true);");
|
||||
}
|
||||
|
||||
private function konfiguriereNetzfahrplanV4Daten(): void
|
||||
{
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0);
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
|
||||
try {
|
||||
$this->v4DatenKonfiguration();
|
||||
if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) {
|
||||
// One retry after the data-only upgrade, never a cursor reset or a device write.
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1);
|
||||
}
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000);
|
||||
} catch (\Throwable $e) {
|
||||
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function v4DatenKonfiguration(): array
|
||||
{
|
||||
$id=$this->ReadAttributeString('LizenzInstallationID');
|
||||
$dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz');
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$dataset)
|
||||
|| !preg_match('/^[0-9a-f-]{36}$/D',$id)) throw new \RuntimeException('Dataset/installation not configured');
|
||||
$c=json_decode($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'),true,64,JSON_THROW_ON_ERROR);
|
||||
$c=NetzfahrplanV4Messaufnahme::configuration($c);
|
||||
if ($c['installationId']!==$id || ($c['managerId']??null)!==$this->InstanceID) throw new \RuntimeException('Measurement mapping belongs to another manager');
|
||||
return $c;
|
||||
}
|
||||
|
||||
private function v4DatenVerzeichnis(): string
|
||||
{
|
||||
$base=rtrim(IPS_GetKernelDir(),'/').'/data/enelix-v4';
|
||||
$directory=$base.'/'.$this->ReadAttributeString('LizenzInstallationID');
|
||||
// Fixed application-owned location; no configurable path or arbitrary file access.
|
||||
foreach ([$base,$directory] as $p) {
|
||||
if (is_link($p)) throw new \RuntimeException('Measurement directory symlink refused');
|
||||
if (!is_dir($p) && !mkdir($p,0700,true) && !is_dir($p)) throw new \RuntimeException('Measurement directory unavailable');
|
||||
if ((fileperms($p)&0007)!==0) throw new \RuntimeException('Measurement directory must remain private');
|
||||
}
|
||||
return $directory;
|
||||
}
|
||||
|
||||
private function v4DatenStatus(array $data): void
|
||||
{
|
||||
$receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
|
||||
$data['lastAcknowledgement']=$receipt;
|
||||
$this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'),
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function ErfasseNetzfahrplanV4Daten(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
|
||||
$lock='ENELIX.V4.ApplicationData.'.$this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock,0)) return;
|
||||
try {
|
||||
$c=$this->v4DatenKonfiguration();
|
||||
$reader=static function(int $id):array {
|
||||
if (!IPS_VariableExists($id)) throw new \RuntimeException('Missing measurement');
|
||||
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);
|
||||
if (!in_array($v['VariableType'],[1,2],true)) throw new \RuntimeException('Numeric source required');
|
||||
return ['value'=>GetValue($id),'updated'=>(int)$v['VariableUpdated'],'changed'=>(int)$v['VariableChanged'],
|
||||
'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
|
||||
};
|
||||
$dir=$this->v4DatenVerzeichnis();
|
||||
$archiveResult=null;
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanV4ArchivAktiv') && $this->ReadAttributeString('NetzfahrplanV4ArchivTag')!==gmdate('Y-m-d')) {
|
||||
try {
|
||||
$archiveCursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
|
||||
$archiveResult=NetzfahrplanV4Datenarchiv::run($dir,$archiveCursor,time());
|
||||
if ($archiveResult['archived']<2 && ($archiveResult['status']??null)!=='writer_busy')$this->WriteAttributeString('NetzfahrplanV4ArchivTag',gmdate('Y-m-d'));
|
||||
} catch (\Throwable $e) { $archiveResult=['status'=>'archive_error','errorType'=>get_class($e),'originalsRetained'=>true]; }
|
||||
}
|
||||
try {
|
||||
$r=NetzfahrplanV4Messaufnahme::capture($c,$reader,static fn():int=>time());
|
||||
NetzfahrplanV4Messaufnahme::append($dir,$r);
|
||||
$result=['status'=>'recorded','capturedAt'=>$r['capturedAt'],'sourceCount'=>count($r['raw']),
|
||||
'rawIssues'=>count($r['issues']),'measurementBoundaryVerified'=>false];
|
||||
} catch (\Throwable $e) {
|
||||
// Storage/capture failure must not prevent retrying the already durable outbox.
|
||||
$result=['status'=>'capture_error','errorType'=>get_class($e),'dataRetained'=>true];
|
||||
}
|
||||
if ($archiveResult!==null)$result['archive']=$archiveResult;
|
||||
$last=$this->ReadAttributeInteger('NetzfahrplanV4DatenVersuch');
|
||||
$failures=min(5,$this->ReadAttributeInteger('NetzfahrplanV4DatenFehlerzahl'));
|
||||
// Planner inputs expire after 120 s. Drain an acknowledged backlog faster, but a
|
||||
// failed HTTP attempt still backs off to 60 s. Once caught up, normal upload is 60 s.
|
||||
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
|
||||
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
|
||||
$backlog=$acceptedEpoch===false || time()-$acceptedEpoch>120;
|
||||
$retry=$failures>0?60:($backlog?15:60);
|
||||
if (time()>=$last+$retry) {
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch',time());
|
||||
try {
|
||||
$result['delivery']=$this->v4DatenUebertragen($dir);
|
||||
if (($result['delivery']['status']??null)==='acknowledged') {
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteDatenquittung',json_encode($result['delivery']+['receivedAt'=>gmdate('c')],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',0);
|
||||
} catch (\Throwable $e) {
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl',$failures+1);
|
||||
$httpCode=(int)$e->getCode();
|
||||
$result['delivery']=['status'=>'retry_pending','errorType'=>get_class($e),
|
||||
'httpStatus'=>$httpCode>=100&&$httpCode<=599?$httpCode:null,'dataRetained'=>true,
|
||||
'nextAttemptAt'=>gmdate('c',time()+60)];
|
||||
}
|
||||
} else $result['delivery']=['status'=>'scheduled','nextAttemptAt'=>gmdate('c',$last+$retry)];
|
||||
$this->v4DatenStatus($result);
|
||||
} catch (\Throwable $e) {
|
||||
// Sensor/configuration exceptions never enter the actuator or module-status path.
|
||||
$this->v4DatenStatus(['status'=>'capture_error','errorType'=>get_class($e)]);
|
||||
} finally { IPS_SemaphoreLeave($lock); }
|
||||
}
|
||||
|
||||
/** Returns only complete records from the append-only prefix; cursor moves after matching ACK. */
|
||||
private function v4DatenUebertragen(string $dir): array
|
||||
{
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new \RuntimeException('Forecast licence missing');
|
||||
$cursor=json_decode($this->ReadAttributeString('NetzfahrplanV4DatenCursor'),true,32,JSON_THROW_ON_ERROR);
|
||||
$day=$cursor['day']??'';$offset=$cursor['offset']??0;
|
||||
if (!is_string($day) || ($day!==''&&!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)) || !is_int($offset)||$offset<0) throw new \RuntimeException('Invalid data cursor');
|
||||
$files=glob($dir.'/raw-*.jsonl');
|
||||
if ($files===false||count($files)>400) throw new \RuntimeException('Measurement journal bounds exceeded');
|
||||
sort($files,SORT_STRING);
|
||||
$lastAck=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
|
||||
$acceptedEpoch=strtotime((string)($lastAck['acceptedThrough']??''));
|
||||
if ($acceptedEpoch===false) $acceptedEpoch=null;
|
||||
$records=[];$candidate=$cursor;$bytes=0;$lastTime=null;$lastEpoch=null;$skippedDuplicateTimestamps=0;$skippedAcknowledgedTimestamps=0;
|
||||
foreach ($files as $file) {
|
||||
$name=basename($file);
|
||||
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||($day!==''&&$name<$day)) continue;
|
||||
if (is_link($file)||!is_file($file)||realpath($file)!==$file) throw new \RuntimeException('Invalid measurement journal');
|
||||
$h=fopen($file,'rb');if($h===false)throw new \RuntimeException('Cannot open measurement journal');
|
||||
try {
|
||||
$size=fstat($h)['size'];$position=$name===$day?$offset:0;
|
||||
if ($position>$size||fseek($h,$position)!==0) throw new \RuntimeException('Measurement journal shortened');
|
||||
while (ftell($h)<$size) {
|
||||
$line=fgets($h,min(262146,$size-ftell($h)+1));
|
||||
if ($line===false||!str_ends_with($line,"\n")) break;
|
||||
if (strlen($line)>262144) throw new \RuntimeException('Oversized measurement');
|
||||
if (count($records)>=120||$bytes+strlen($line)>900000) break 2;
|
||||
$r=json_decode($line,false,64,JSON_THROW_ON_ERROR);
|
||||
if (!is_object($r)||($r->installationId??null)!==$this->ReadAttributeString('LizenzInstallationID')
|
||||
||($r->kind??null)!=='raw_accounting_capture') throw new \RuntimeException('Journal identity invalid');
|
||||
$epoch=strtotime((string)($r->capturedAt??''));
|
||||
if ($epoch===false) throw new \RuntimeException('Journal capture timestamp invalid');
|
||||
$candidate=['day'=>$name,'offset'=>ftell($h)];
|
||||
if ($acceptedEpoch!==null && $epoch<=$acceptedEpoch) { $skippedAcknowledgedTimestamps++;continue; }
|
||||
$bytes+=strlen($line);
|
||||
if ($lastEpoch!==null && $epoch<$lastEpoch) throw new \RuntimeException('Measurement journal timestamp moved backwards');
|
||||
if ($lastEpoch!==null && $epoch===$lastEpoch) { $skippedDuplicateTimestamps++;continue; }
|
||||
$records[]=$r;$lastTime=$r->capturedAt;$lastEpoch=$epoch;
|
||||
}
|
||||
} finally { fclose($h); }
|
||||
}
|
||||
if ($records===[]) return ['status'=>'up_to_date'];
|
||||
$id=$this->ReadAttributeString('LizenzInstallationID');$token=$this->ReadAttributeString('PrognoseInstallationsToken');
|
||||
if ($token==='') throw new \RuntimeException('Existing device token unavailable');
|
||||
$payload=json_encode(['version'=>1,'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'records'=>$records],JSON_THROW_ON_ERROR);
|
||||
$h=curl_init('https://license.enelix.ch/api/v1/installations/'.rawurlencode($id).'/prognosis/planner-v4/measurements');
|
||||
if ($h===false) throw new \RuntimeException('Cannot prepare data transfer');
|
||||
try {
|
||||
curl_setopt_array($h,[CURLOPT_POST=>true,CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>false,
|
||||
CURLOPT_CONNECTTIMEOUT=>2,CURLOPT_TIMEOUT=>8,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,
|
||||
CURLOPT_HTTPHEADER=>['Content-Type: application/json','Accept: application/json','Authorization: Bearer '.$token],
|
||||
CURLOPT_POSTFIELDS=>$payload]);
|
||||
$answer=curl_exec($h);$code=(int)curl_getinfo($h,CURLINFO_HTTP_CODE);
|
||||
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code);
|
||||
$ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR);
|
||||
if (!in_array($ack['status']??null,['stored','duplicate'],true)
|
||||
||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz')
|
||||
||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch');
|
||||
$this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR));
|
||||
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime,
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),
|
||||
'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps,
|
||||
'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps];
|
||||
} finally { curl_close($h); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
|
||||
trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
{
|
||||
private function registriereNetzfahrplanV4Empfang(): void
|
||||
{
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4EmpfangAktiv', false);
|
||||
$this->RegisterAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4EmpfangStatus', '{}');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
||||
$this->RegisterAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
|
||||
$this->RegisterVariableString('NetzfahrplanV4Vorschau', 'V4 Sollwert-Vorschau (KEINE Stellfreigabe)', '~HTMLBox', 950);
|
||||
$this->RegisterVariableString('NetzfahrplanV4VorschauJSON', 'V4 Vorschau Diagnose (nur lesen)', '', 951);
|
||||
$this->RegisterTimer('NetzfahrplanV4VorschauAktualisieren', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4VorschauAktualisieren', true);");
|
||||
$this->RegisterTimer('NetzfahrplanV4EmpfangAktualisieren', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Empfangen', true);");
|
||||
}
|
||||
|
||||
private function stoppeNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', 0);
|
||||
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', 0);
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', '{}');
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', '{"status":"not_received"}');
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', 0);
|
||||
$this->zeigeNetzfahrplanV4Vorschau(['status' => 'not_received', 'reason' => 'Neu initialisiert; keinen alten Sollwert weiterverwenden.']);
|
||||
}
|
||||
|
||||
private function konfiguriereNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|
||||
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|
||||
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
|
||||
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
|
||||
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', $enabled ? 10000 : 0);
|
||||
$this->aktualisiereNetzfahrplanV4Vorschau();
|
||||
}
|
||||
|
||||
/** Called before the next operation upload, so the preceding completed plan can be seen. */
|
||||
private function empfangeNetzfahrplanV4(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
|
||||
$lock = 'ENELIX.V4.Receive.' . $this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock, 0)) return;
|
||||
try {
|
||||
$now = time();
|
||||
if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return;
|
||||
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
|
||||
$interval = ($session['kind'] ?? null) === 'local_active_test_consent' ? 45 : 60;
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + $interval);
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.');
|
||||
}
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
}
|
||||
$response = $this->netzfahrplanV4EmpfangHttp('GET', '');
|
||||
$receipt = time();
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
|
||||
}
|
||||
// Validate the candidate before replacing the last accepted envelope. During an
|
||||
// active test a failed refresh may keep the previous envelope, but its original
|
||||
// receivedAt is never changed, so invalid traffic can never extend its lifetime.
|
||||
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($response,
|
||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $receipt);
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
// Only the known, non-secret protocol envelope is cached.
|
||||
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh',
|
||||
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial'];
|
||||
$safe = array_intersect_key($response, array_flip($keys));
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
|
||||
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
|
||||
$state = ['status' => 'received_shadow', 'at' => gmdate('c', $receipt), 'acknowledged' => false];
|
||||
$ack = ['planId' => $preview['planId'], 'revision' => $preview['revision'], 'step' => $preview['step'], 'status' => 'shadow_seen'];
|
||||
$last = json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteBestaetigung'), true);
|
||||
if ($last !== $ack) {
|
||||
try {
|
||||
$answer = $this->netzfahrplanV4EmpfangHttp('POST', '/ack', $ack);
|
||||
if (($answer['status'] ?? null) !== 'recorded') throw new RuntimeException('Empfangsbestaetigung abgelehnt.');
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteBestaetigung', json_encode($ack, JSON_THROW_ON_ERROR));
|
||||
$state['acknowledged'] = true;
|
||||
} catch (Throwable $error) {
|
||||
$state['acknowledgementError'] = substr($error->getMessage(), 0, 200);
|
||||
}
|
||||
} else {
|
||||
$state['acknowledged'] = true;
|
||||
}
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$this->aktualisiereNetzfahrplanV4Vorschau();
|
||||
} catch (Throwable $error) {
|
||||
$keepPrevious = false;
|
||||
$previous = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true);
|
||||
if (is_array($previous) && is_int($previous['receivedAt'] ?? null)) {
|
||||
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
|
||||
$keepPrevious = (($session['kind'] ?? null) === 'local_active_test_consent')
|
||||
&& $previous['receivedAt'] <= time()
|
||||
&& time() - $previous['receivedAt'] <= NetzfahrplanV4Planpruefung::MAX_RECEIPT_AGE;
|
||||
}
|
||||
$pendingRetry = is_array($response ?? null)
|
||||
&& ($response['installationId'] ?? null) === $this->ReadAttributeString('LizenzInstallationID')
|
||||
&& ($response['pending'] ?? null) !== null;
|
||||
if ($keepPrevious || $pendingRetry) {
|
||||
// Also catch a completed plan before the next operation starts another calculation.
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', time() + 15);
|
||||
}
|
||||
if (!$keepPrevious) {
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', '{}');
|
||||
}
|
||||
$state = ['status' => 'rejected_or_unavailable', 'at' => gmdate('c'),
|
||||
'reason' => substr($error->getMessage(), 0, 300), 'previousEnvelopeRetained' => $keepPrevious];
|
||||
$this->WriteAttributeString('NetzfahrplanV4EmpfangStatus', json_encode($state, JSON_THROW_ON_ERROR));
|
||||
$this->zeigeNetzfahrplanV4Vorschau($state);
|
||||
} finally {
|
||||
IPS_SemaphoreLeave($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed authenticated read/ack endpoints only; never operation, settings, replan or dispatch. */
|
||||
private function netzfahrplanV4EmpfangHttp(string $method, string $suffix, ?array $body = null): array
|
||||
{
|
||||
if (!(($method === 'GET' && $suffix === '' && $body === null)
|
||||
|| ($method === 'POST' && $suffix === '/ack' && ($body['status'] ?? null) === 'shadow_seen'))) {
|
||||
throw new RuntimeException('Unzulaessiger V4-Empfangsaufruf.');
|
||||
}
|
||||
$id = $this->ReadAttributeString('LizenzInstallationID');
|
||||
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $id)
|
||||
|| !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
|
||||
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
|
||||
}
|
||||
$handle = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
|
||||
if ($handle === false) throw new RuntimeException('V4-Empfang konnte nicht vorbereitet werden.');
|
||||
$response = '';
|
||||
try {
|
||||
$options = [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token,
|
||||
'Accept: application/json', 'Content-Type: application/json', 'Cache-Control: no-cache'],
|
||||
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
|
||||
if (strlen($response) + strlen($chunk) > NetzfahrplanV4Planpruefung::MAX_RESPONSE_BYTES) return 0;
|
||||
$response .= $chunk;return strlen($chunk);
|
||||
}];
|
||||
if ($body !== null) $options[CURLOPT_POSTFIELDS] = json_encode($body, JSON_THROW_ON_ERROR);
|
||||
curl_setopt_array($handle, $options);
|
||||
$ok = curl_exec($handle);$code = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($ok === false || $code !== 200) throw new RuntimeException('V4-Planempfang HTTP ' . $code . '; kein gueltiger Vorschauwert.');
|
||||
return NetzfahrplanV4Planpruefung::dekodieren($response);
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads independently of the old control loop. Never manufactures fresh timestamps. */
|
||||
private function netzfahrplanV4LokaleVorschauwerte(): array
|
||||
{
|
||||
$d = json_decode($this->GetNetzfahrplanV4Diagnose(), true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($d['operation'] ?? null)) throw new RuntimeException('Lokale Betriebsdaten ungueltig; Vorschau gesperrt.');
|
||||
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$actual = [];$now = time();
|
||||
foreach ($topology as $asset) {
|
||||
$controllers=[];
|
||||
foreach ($this->aktiveVerbraucherIDs() as $controller) {
|
||||
if (IPS_InstanceExists($controller)
|
||||
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
|
||||
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
|
||||
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
|
||||
}
|
||||
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
|
||||
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
|
||||
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
|
||||
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
|
||||
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
|
||||
$feedbackNow=time();
|
||||
if (($f['status']??null)==='unavailable') {
|
||||
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
|
||||
throw new RuntimeException('feedback_unavailable:'.substr($reason,0,180));
|
||||
}
|
||||
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|
||||
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|
||||
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|
||||
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null)
|
||||
|| $feedbackNow-$f['checkedAt']>2 || $f['checkedAt']>$feedbackNow) {
|
||||
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
|
||||
}
|
||||
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
|
||||
$d['operation']['localFeedback'][$asset['ID']]=$f;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Legacy value remains display-only until an explicit physical feedback mapping exists.
|
||||
$id = (int) ($asset['LeistungVariableID'] ?? 0);
|
||||
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
|
||||
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
|
||||
$factor = $asset['Leistungsfaktor'] ?? null;
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v)
|
||||
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor) || $factor == 0
|
||||
|| $before['VariableUpdated'] !== $after['VariableUpdated']
|
||||
|| $after['VariableUpdated'] > $now || $now - $after['VariableUpdated'] > $this->ReadPropertyInteger('MesswertMaxAlter')) {
|
||||
throw new RuntimeException('Batterie-Istleistung fehlt, ist veraltet oder inkonsistent.');
|
||||
}
|
||||
$actual[$asset['ID']] = (float) $v * $factor;
|
||||
}
|
||||
return [$d['operation'], $actual];
|
||||
}
|
||||
|
||||
public function GetNetzfahrplanV4Vorschau(): string
|
||||
{
|
||||
try {
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
throw new RuntimeException('V4-Empfang ausgeschaltet.');
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.');
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
$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('Noch kein gueltiger V4-Plan empfangen.');
|
||||
}
|
||||
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
|
||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
|
||||
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
$out['localFeedback'] = $operation['localFeedback'] ?? [];
|
||||
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
|
||||
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
|
||||
} catch (Throwable $error) {
|
||||
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300),
|
||||
'reception' => json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true)];
|
||||
}
|
||||
return json_encode(array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null],
|
||||
$out, ['canDispatch' => false, 'liveEnabled' => false]), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function aktualisiereNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$this->zeigeNetzfahrplanV4Vorschau(json_decode($this->GetNetzfahrplanV4Vorschau(), true, 64, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function zeigeNetzfahrplanV4Vorschau(array $value): void
|
||||
{
|
||||
$value = array_merge(['gridTargetW' => null, 'plannedBatteryW' => null, 'previewBatteryW' => null], $value,
|
||||
['canDispatch' => false, 'liveEnabled' => false]);
|
||||
$escape = static fn($v): string => htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$html = '<h3>V4: NUR VORSCHAU - KEINE STELLFREIGABE</h3><table>';
|
||||
foreach (['status' => 'Status', 'planId' => 'Plan-ID', 'family' => 'Modellfamilie', 'step' => 'Zeitschritt (UTC)',
|
||||
'stepValidUntil' => 'Gueltig bis', 'gridTargetW' => 'Geplantes Netzziel W', 'plannedBatteryW' => 'Geplante Batterie W',
|
||||
'previewBatteryW' => 'Lokal berechnete Batterie W (nicht ausgegeben)', 'reason' => 'Ablehnungsgrund'] as $key => $label) {
|
||||
$item = $value[$key] ?? '-';if (is_float($item)) $item = number_format($item, 1, '.', "'");
|
||||
$html .= '<tr><th>' . $escape($label) . '</th><td>' . $escape($item) . '</td></tr>';
|
||||
}
|
||||
$html .= '</table><p>Batterie + = Laden, - = Entladen. Netz + = Bezug, - = Einspeisung.</p>';
|
||||
foreach ($value['reasons'] ?? [] as $reason) $html .= '<p>' . $escape($reason) . '</p>';
|
||||
$html .= '<p>Empfangsbestaetigung ist keine Ausfuehrungsbestaetigung.</p>';
|
||||
$this->SetValue('NetzfahrplanV4VorschauJSON', json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
|
||||
$this->SetValue('NetzfahrplanV4Vorschau', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/ManagerNetzfahrplanV4EmpfangTrait.php';
|
||||
require_once __DIR__ . '/ManagerNetzfahrplanV4DatenTrait.php';
|
||||
|
||||
/** Shadow telemetry only. Does not read/apply a V4 schedule or write actuators. */
|
||||
trait ManagerNetzfahrplanV4Trait
|
||||
{
|
||||
use ManagerNetzfahrplanV4EmpfangTrait;
|
||||
use ManagerNetzfahrplanV4DatenTrait;
|
||||
private function registriereNetzfahrplanV4(): void
|
||||
{
|
||||
$this->registriereNetzfahrplanV4Empfang();
|
||||
$this->registriereNetzfahrplanV4Daten();
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false);
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4NetzladenErlaubt', false);
|
||||
$this->RegisterPropertyString('NetzfahrplanV4BatterieOptionen', '{}');
|
||||
$this->RegisterPropertyInteger('NetzfahrplanV4MessnachweisVariableID', 0);
|
||||
$this->RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]');
|
||||
$this->RegisterAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
||||
$this->RegisterTimer('NetzfahrplanV4Senden', 0,
|
||||
"IPS_RequestAction(\$_IPS['TARGET'], 'NetzfahrplanV4Senden', true);");
|
||||
}
|
||||
|
||||
public function GetNetzfahrplanV4Diagnose(): string
|
||||
{
|
||||
try {
|
||||
$manager = [];
|
||||
foreach (['NetzleistungVariableID', 'MesswertMaxAlter', 'VerbraucherTimeout',
|
||||
'Lastspitzenmodus'] as $key) {
|
||||
$manager[$key] = $this->ReadPropertyInteger($key);
|
||||
}
|
||||
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);
|
||||
$manager['BatterieOptionen'] = json_decode($this->ReadPropertyString('NetzfahrplanV4BatterieOptionen'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$manager['V4BezugszaehlerQuellen'] = json_decode(
|
||||
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
|
||||
);
|
||||
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
||||
$controllers = [];
|
||||
foreach ($this->aktiveVerbraucherIDs() as $id) {
|
||||
if (!IPS_InstanceExists($id)
|
||||
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') {
|
||||
continue;
|
||||
}
|
||||
$c = ['InstanzID' => $id];
|
||||
foreach (['LadezustandVariableID', 'MaxLadeleistungVariableID', 'MaxEntladeleistungVariableID',
|
||||
'IstleistungVariableID', 'MindestLadezustand', 'ReserveLadezustand', 'LadezustandHysterese', 'MesswertMaxAlter', 'Batteriemanagement'] as $key) {
|
||||
$c[$key] = IPS_GetProperty($id, $key);
|
||||
}
|
||||
$entry = $cache[(string) $id] ?? [];
|
||||
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
|
||||
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
|
||||
if (function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
|
||||
$configured = IPS_GetProperty($id, 'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (is_string($configured) && !in_array(trim($configured), ['', '{}'], true)) {
|
||||
try {
|
||||
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($id), true, 32, JSON_THROW_ON_ERROR);
|
||||
$checkedAt = $feedback['checkedAt'] ?? null;
|
||||
if (($feedback['usableForTrial'] ?? false) === true
|
||||
&& ($feedback['deviceReadConfirmed'] ?? false) === true
|
||||
&& is_int($checkedAt) && $checkedAt <= time()
|
||||
&& time() - $checkedAt <= max(2, (int) ($c['MesswertMaxAlter'] ?? 30))) {
|
||||
$c['EmpfangenAm'] = $checkedAt;
|
||||
$c['Verfuegbar'] = true;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Keep the ordinary cache state; V4 remains fail-safe if direct feedback is unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (($entry['Daten']['Zustand'] ?? []) as $state) {
|
||||
if (($state['Kennung'] ?? '') === 'HystereseAktiv') {
|
||||
$c['HystereseAktiv'] = $state['Wert'];
|
||||
}
|
||||
}
|
||||
$controllers[] = $c;
|
||||
}
|
||||
$evidence = [];
|
||||
$evidenceID = $this->ReadPropertyInteger('NetzfahrplanV4MessnachweisVariableID');
|
||||
if ($evidenceID > 0) {
|
||||
if (!IPS_VariableExists($evidenceID) || IPS_GetVariable($evidenceID)['VariableType'] !== 3) {
|
||||
throw new RuntimeException('Der konfigurierte Messnachweis ist keine JSON-Stringvariable.');
|
||||
}
|
||||
$evidence = json_decode(GetValue($evidenceID), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($evidence)) {
|
||||
throw new RuntimeException('Ungueltiger Messnachweis.');
|
||||
}
|
||||
}
|
||||
$read = static function (int $id): array {
|
||||
if (!IPS_VariableExists($id)) {
|
||||
throw new RuntimeException('Messvariable ' . $id . ' fehlt.');
|
||||
}
|
||||
$before = IPS_GetVariable($id);
|
||||
$value = GetValue($id);
|
||||
$after = IPS_GetVariable($id);
|
||||
if ($before['VariableUpdated'] !== $after['VariableUpdated']) {
|
||||
throw new RuntimeException('Messwert hat sich beim Lesen geaendert; naechsten Durchlauf abwarten.');
|
||||
}
|
||||
$object = IPS_GetObject($id);
|
||||
return ['value' => $value, 'updated' => (int) $after['VariableUpdated'],
|
||||
'ident' => $object['ObjectIdent'], 'parentID' => (int) $object['ParentID']];
|
||||
};
|
||||
return json_encode(NetzfahrplanV4Betriebsdaten::erstellen($manager, $assets, $controllers,
|
||||
$read, $evidence, time()), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
} catch (Throwable $error) {
|
||||
return json_encode(['status' => 'invalid_inputs', 'reason' => substr($error->getMessage(), 0, 300)], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public function GetNetzfahrplanV4Sendestatus(): string
|
||||
{
|
||||
return $this->ReadAttributeString('NetzfahrplanV4Sendestatus');
|
||||
}
|
||||
|
||||
private function sendeNetzfahrplanV4(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
||||
return;
|
||||
}
|
||||
// Read the preceding completed plan BEFORE sending a new operation/replan trigger.
|
||||
$this->empfangeNetzfahrplanV4();
|
||||
try {
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
}
|
||||
$snapshot = $this->GetNetzfahrplanV4Diagnose();
|
||||
$data = json_decode($snapshot, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!isset($data['operation'])) {
|
||||
throw new RuntimeException($data['reason'] ?? 'Betriebsdaten unvollstaendig.');
|
||||
}
|
||||
// Decoding as objects preserves empty JSON dictionaries in the API contract.
|
||||
$payload = json_decode($snapshot, false, 512, JSON_THROW_ON_ERROR);
|
||||
if (!isset($payload->operation)) {
|
||||
throw new RuntimeException('Keine konsistenten Betriebsdaten.');
|
||||
}
|
||||
$id = $this->ReadAttributeString('LizenzInstallationID');
|
||||
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
|
||||
if (!preg_match('/^[0-9a-f-]{36}$/i', $id) || $token === '') {
|
||||
throw new RuntimeException('Installations-ID oder bestehender Geraetezugang fehlt.');
|
||||
}
|
||||
$url = 'https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4/operation';
|
||||
$handle = curl_init($url);
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('V4-Verbindung konnte nicht vorbereitet werden.');
|
||||
}
|
||||
try {
|
||||
curl_setopt_array($handle, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload->operation, JSON_THROW_ON_ERROR),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . $token]]);
|
||||
$answer = curl_exec($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
||||
if ($answer === false || $status !== 200) {
|
||||
throw new RuntimeException('V4-Schattenanbindung HTTP ' . $status . '; bestehende Regelung unveraendert.');
|
||||
}
|
||||
if (strlen($answer) > 65536) {
|
||||
throw new RuntimeException('V4-Antwort ist zu gross.');
|
||||
}
|
||||
$ack = json_decode($answer, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!in_array($ack['status'] ?? '', ['stored', 'duplicate', 'archived_older'], true)) {
|
||||
throw new RuntimeException('Unerwartete V4-Annahmebestaetigung.');
|
||||
}
|
||||
} finally {
|
||||
curl_close($handle);
|
||||
}
|
||||
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
|
||||
'status' => 'sent_shadow', 'capturedAt' => $payload->operation->observedAt,
|
||||
'acceptance' => $ack['status'], 'warnings' => $data['warnings'], 'liveEnabled' => false], JSON_THROW_ON_ERROR));
|
||||
} catch (Throwable $error) {
|
||||
// Only this sender's diagnosis changes; never SetStatus, live plan or actuator commands.
|
||||
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', json_encode([
|
||||
'status' => 'error', 'reason' => substr($error->getMessage(), 0, 300), 'liveEnabled' => false], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use LogicException;
|
||||
|
||||
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
|
||||
final class ManagerPrognoseFormular
|
||||
{
|
||||
public static function startErlaubt(array $zustand): bool
|
||||
{
|
||||
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
|
||||
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $feld) {
|
||||
if (($zustand[$feld] ?? false) !== true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return ($zustand['legacyAktiv'] ?? true) === false
|
||||
&& ($zustand['aktivtestAktiv'] ?? true) === false
|
||||
&& isset($zustand['ende'], $zustand['jetzt'])
|
||||
&& is_int($zustand['ende']) && is_int($zustand['jetzt'])
|
||||
&& ($zustand['ende'] === 0 || $zustand['ende'] > $zustand['jetzt']);
|
||||
}
|
||||
|
||||
public static function statustext(array $zustand): string
|
||||
{
|
||||
$text = ($zustand['aktivtestAktiv'] ?? false)
|
||||
? 'Netzfahrplan: befristeter Testbetrieb eingeschaltet.'
|
||||
: 'Netzfahrplan: Steuerung ausgeschaltet.';
|
||||
if ($zustand['legacyAktiv'] ?? false) {
|
||||
$text = 'Bisheriger Netzfahrplan eingeschaltet; neuer Netzfahrplan gesperrt.';
|
||||
}
|
||||
if (!($zustand['lizenziert'] ?? false)) {
|
||||
$text .= ' Netzfahrplanlizenz fehlt.';
|
||||
}
|
||||
$ende = $zustand['ende'] ?? 0;
|
||||
if (is_int($ende) && $ende > 0) {
|
||||
$text .= ' Bestehendes Testende: ' . gmdate('d.m.Y H:i:s', $ende) . ' UTC.';
|
||||
if ($ende <= ($zustand['jetzt'] ?? 0)) {
|
||||
$text .= ' Testfreigabe abgelaufen.';
|
||||
}
|
||||
}
|
||||
return $text . ' Kein freigegebener Dauerbetrieb. Bestehende Laufzeit-, Messwert- und Geraeteschutzpruefungen bleiben wirksam.';
|
||||
}
|
||||
|
||||
public static function integrieren(array $formular, array $zustand): array
|
||||
{
|
||||
$gefunden = false;
|
||||
$v4Konfiguriert = ($zustand['senderAktiv'] ?? false)
|
||||
|| ($zustand['empfangAktiv'] ?? false) || ($zustand['messdatenAktiv'] ?? false);
|
||||
foreach ($formular['elements'] as &$element) {
|
||||
if (($element['name'] ?? null) !== 'PrognoseForecast') {
|
||||
continue;
|
||||
}
|
||||
if ($gefunden) {
|
||||
throw new LogicException('Prognosebereich ist nicht eindeutig.');
|
||||
}
|
||||
$gefunden = true;
|
||||
foreach ($element['items'] as &$feld) {
|
||||
if (($feld['name'] ?? null) === 'NetzfahrplanAktiv' && $v4Konfiguriert) {
|
||||
// An active legacy controller remains visible so it can still be disabled.
|
||||
$feld['visible'] = (bool) ($zustand['legacyAktiv'] ?? false);
|
||||
$feld['enabled'] = (bool) ($zustand['legacyAktiv'] ?? false);
|
||||
$feld['caption'] = 'Bisheriger Netzfahrplan (konkurrierende Steuerung)';
|
||||
}
|
||||
}
|
||||
unset($feld);
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseLizenzVerwalten',
|
||||
'caption' => 'Prognose und Netzfahrplan im Lizenzportal verwalten', 'link' => true,
|
||||
'onClick' => "echo 'https://license.enelix.ch';",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv',
|
||||
'caption' => 'Aktuellen Netzfahrplan empfangen und anzeigen',
|
||||
'enabled' => (bool) (($zustand['lizenziert'] ?? false) || ($zustand['empfangAktiv'] ?? false)),
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus',
|
||||
'caption' => self::statustext($zustand),
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStart',
|
||||
'caption' => 'Netzfahrplan einschalten (Testbetrieb)',
|
||||
'enabled' => self::startErlaubt($zustand),
|
||||
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', true);",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStop',
|
||||
'caption' => 'Netzfahrplan ausschalten', 'enabled' => true,
|
||||
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', false);",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanPruefen',
|
||||
'caption' => 'Plan jetzt pruefen',
|
||||
'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);",
|
||||
];
|
||||
}
|
||||
unset($element);
|
||||
if (!$gefunden) {
|
||||
throw new LogicException('Prognosebereich fehlt; Formular nicht erweitert.');
|
||||
}
|
||||
return $formular;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Bezugszaehler.php';
|
||||
|
||||
/** Read-only conversion. Meter evidence is never derived from a configured limit. */
|
||||
final class NetzfahrplanV4Betriebsdaten
|
||||
{
|
||||
private static function number($value, string $label, float $min = -1.0e9, float $max = 1.0e9): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|
||||
|| $value < $min || $value > $max) {
|
||||
throw new InvalidArgumentException($label . ': ungueltiger Zahlenwert.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
private static function timestamp($value): int
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/(?:Z|[+-]\d{2}:\d{2})$/', $value)) {
|
||||
throw new InvalidArgumentException('Messnachweis benoetigt einen Zeitpunkt mit Zeitzone.');
|
||||
}
|
||||
return (new DateTimeImmutable($value))->getTimestamp();
|
||||
}
|
||||
|
||||
/** $read returns ['value' => int|float, 'updated' => int], without changing a device. */
|
||||
private static function measurement(callable $read, int $id, int $now, int $age, string $label): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new InvalidArgumentException($label . ': Messquelle fehlt.');
|
||||
}
|
||||
$m = $read($id);
|
||||
if (!isset($m['updated']) || !is_int($m['updated']) || $m['updated'] > $now
|
||||
|| $now - $m['updated'] > $age) {
|
||||
throw new InvalidArgumentException($label . ': Messwert fehlt, ist veraltet oder liegt in der Zukunft.');
|
||||
}
|
||||
return ['value' => self::number($m['value'] ?? null, $label), 'updated' => $m['updated']];
|
||||
}
|
||||
|
||||
/** No inference of paid peak or elapsed energy from power, reserve or forecast. */
|
||||
private static function evidence(array $input, int $now, string $meterID, array &$warnings): array
|
||||
{
|
||||
$peaks = [];
|
||||
$past = null;
|
||||
if ($input === []) {
|
||||
$warnings[] = 'Autoritativer Monatspeak und laufende Viertelstundenenergie fehlen.';
|
||||
return [(object) [], null];
|
||||
}
|
||||
if ($meterID === '' || ($input['version'] ?? null) !== 1 || ($input['meterId'] ?? null) !== $meterID) {
|
||||
throw new InvalidArgumentException('Messnachweis: falsche Version oder Bezugszaehler-Zuordnung.');
|
||||
}
|
||||
$measured = self::timestamp($input['measuredAt'] ?? null);
|
||||
if ($measured > $now || $now - $measured > 120) {
|
||||
throw new InvalidArgumentException('Messnachweis ist nicht aktuell.');
|
||||
}
|
||||
$month = (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('Y-m');
|
||||
foreach (($input['measuredPeaks'] ?? []) as $key => $p) {
|
||||
if (!is_string($key) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $key) || $key > $month
|
||||
|| !in_array($p['source'] ?? '', ['meter_month_register', 'verified_month_history', 'verified_new_month'], true)) {
|
||||
throw new InvalidArgumentException('Messnachweis: Monatspeak ist kein gueltiger Messnachweis.');
|
||||
}
|
||||
$peaks[$key] = ['kw' => self::number($p['kw'] ?? null, 'Monatspeak', 0.0), 'source' => $p['source']];
|
||||
}
|
||||
if (!array_key_exists($month, $peaks)) {
|
||||
$warnings[] = 'Vollstaendiger Monatspeak fuer ' . $month . ' fehlt; Managergrenze ist kein Ersatz.';
|
||||
}
|
||||
$q = intdiv($now, 900) * 900;
|
||||
if (isset($input['quarterPast'])) {
|
||||
$p = $input['quarterPast'];
|
||||
// Stale quarter evidence remains absent, not extrapolated to the decision time.
|
||||
if ($measured === $now && self::timestamp($p['start'] ?? null) === $q
|
||||
&& ($p['measuredSeconds'] ?? null) === $now - $q) {
|
||||
$past = ['start' => gmdate('c', $q),
|
||||
'measuredSeconds' => $now - $q,
|
||||
'importKwh' => self::number($p['importKwh'] ?? null, 'Viertelstundenenergie', 0.0)];
|
||||
} else {
|
||||
$warnings[] = 'Viertelstundenenergie passt nicht exakt zum Entscheidungszeitpunkt; nicht verwendet.';
|
||||
}
|
||||
}
|
||||
if ($now !== $q && $past === null) {
|
||||
$warnings[] = 'Bisherige Energie der laufenden Viertelstunde fehlt; keine Hochrechnung als Messung.';
|
||||
}
|
||||
return [(object) $peaks, $past];
|
||||
}
|
||||
|
||||
/** Inputs are explicit non-secret configuration/cache fields, not full instance settings. */
|
||||
public static function erstellen(array $manager, array $assets, array $controllers, callable $read, array $evidence, int $now): array
|
||||
{
|
||||
$warnings = [];
|
||||
$meter = NetzfahrplanV4Bezugszaehler::lesen(
|
||||
$manager['V4BezugszaehlerQuellen'] ?? [], $read, $now
|
||||
);
|
||||
$grid = self::measurement($read, (int) ($manager['NetzleistungVariableID'] ?? 0), $now,
|
||||
(int) ($manager['MesswertMaxAlter'] ?? 60), 'Netzleistung');
|
||||
$gridW = $grid['value'] * self::number($manager['Netzleistungsfaktor'] ?? 1.0, 'Netzleistungsfaktor');
|
||||
$mode = $manager['Lastspitzenmodus'] ?? 0;
|
||||
$import = null;
|
||||
$months = [];
|
||||
if ($mode === 1) {
|
||||
$import = self::number($manager['Lastspitzengrenze'] ?? null, 'Managergrenze', 0.0);
|
||||
} elseif ($mode === 2) {
|
||||
foreach (($manager['Monatsgrenzen'] ?? []) as $row) {
|
||||
$m = $row['MonatIndex'] ?? null;
|
||||
if (!is_int($m) || $m < 1 || $m > 12 || isset($months[(string) $m])) {
|
||||
throw new InvalidArgumentException('Monatsgrenzen fehlen oder sind doppelt.');
|
||||
}
|
||||
$months[(string) $m] = self::number($row['Grenze_W'] ?? null, 'Monatsgrenze', 0.0);
|
||||
}
|
||||
if (count($months) !== 12) {
|
||||
throw new InvalidArgumentException('Alle zwoelf Monatsgrenzen werden benoetigt.');
|
||||
}
|
||||
} elseif ($mode !== 0) {
|
||||
throw new InvalidArgumentException('Unbekannter Lastspitzenmodus.');
|
||||
}
|
||||
$export = !empty($manager['EinspeisebegrenzungAktiv'])
|
||||
? self::number($manager['Einspeisegrenze'] ?? null, 'Einspeisegrenze', 0.0) : null;
|
||||
$result = [];
|
||||
$seen = [];
|
||||
$used = [];
|
||||
foreach ($assets as $asset) {
|
||||
$id = $asset['ID'] ?? '';
|
||||
if (!is_string($id) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $id) || isset($seen[$id])) {
|
||||
throw new InvalidArgumentException('Batterie-ID fehlt oder ist doppelt.');
|
||||
}
|
||||
$seen[$id] = true;
|
||||
$socID = (int) ($asset['SOCVariableID'] ?? 0);
|
||||
$matches = array_values(array_filter($controllers, static function (array $c) use ($socID): bool {
|
||||
return $socID > 0 && ($c['LadezustandVariableID'] ?? 0) === $socID;
|
||||
}));
|
||||
if (count($matches) !== 1) {
|
||||
throw new InvalidArgumentException($id . ': keine eindeutige aktive Batterieinstanz zur SOC-Quelle.');
|
||||
}
|
||||
$c = $matches[0];
|
||||
$controllerID = $c['InstanzID'];
|
||||
if (isset($used[$controllerID])) {
|
||||
throw new InvalidArgumentException('Eine Batterieinstanz darf nicht doppelt bilanziert werden.');
|
||||
}
|
||||
$used[$controllerID] = true;
|
||||
if (($asset['LeistungVariableID'] ?? 0) !== ($c['IstleistungVariableID'] ?? 0)) {
|
||||
throw new InvalidArgumentException($id . ': Topologie und Batterieinstanz verwenden verschiedene Leistungsmessungen.');
|
||||
}
|
||||
$opts = $manager['BatterieOptionen'][$id] ?? [];
|
||||
$nominal = self::number($asset['Nennkapazitaet_kWh'] ?? null, 'Nennkapazitaet', 0.001);
|
||||
$usable = self::number($asset['Nutzkapazitaet_kWh'] ?? null, 'Nutzkapazitaet', 0.001, $nominal);
|
||||
if (abs($nominal - $usable) > 1.0e-6 && !array_key_exists('SOCKapazitaet_kWh', $opts)) {
|
||||
throw new InvalidArgumentException($id . ': SOC-Kapazitaetsbasis bei verschiedener Nenn-/Nutzkapazitaet bestaetigen.');
|
||||
}
|
||||
$capacity = self::number($opts['SOCKapazitaet_kWh'] ?? $nominal, 'SOC-Kapazitaet', 0.001, $nominal);
|
||||
$age = (int) ($c['MesswertMaxAlter'] ?? 30);
|
||||
$soc = self::measurement($read, $socID, $now, $age, $id . ' SOC');
|
||||
$charge = self::measurement($read, (int) $c['MaxLadeleistungVariableID'], $now, $age, $id . ' max. Laden');
|
||||
$discharge = self::measurement($read, (int) $c['MaxEntladeleistungVariableID'], $now, $age, $id . ' max. Entladen');
|
||||
$min = max(self::number($c['MindestLadezustand'], 'BMS-Minimum', 0, 100),
|
||||
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);
|
||||
$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)) {
|
||||
throw new InvalidArgumentException($id . ': Verbraucher-Rueckmeldung veraltet.');
|
||||
}
|
||||
$maxCharge = min(self::number($charge['value'], 'Ladeleistung', 0), 1000 * self::number($asset['MaxLadeleistung_kW'], 'Nennladeleistung', 0));
|
||||
$maxDischarge = min(self::number($discharge['value'], 'Entladeleistung', 0), 1000 * self::number($asset['MaxEntladeleistung_kW'], 'Nennentladeleistung', 0));
|
||||
if (($c['Verfuegbar'] ?? false) !== true || ($c['Batteriemanagement'] ?? 0) !== 2) {
|
||||
$maxCharge = $maxDischarge = 0.0;
|
||||
$warnings[] = $id . ': nicht fuer Managerregelung verfuegbar; keine Batterieleistung eingeplant.';
|
||||
}
|
||||
$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,
|
||||
'physicalMinSocPercent' => $physicalMin, 'recoveryAllowed' => true,
|
||||
'dischargeBlocked' => $blocked, 'rearmSocPercent' => $rearm];
|
||||
}
|
||||
if ($result === []) {
|
||||
throw new InvalidArgumentException('Keine eindeutige steuerbare Batterie konfiguriert.');
|
||||
}
|
||||
[$peaks, $past] = self::evidence($evidence, $now, $meter['meterId'], $warnings);
|
||||
$payload = ['version' => 1, 'observedAt' => gmdate('c', $now), 'gridW' => $gridW,
|
||||
'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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Read-only active-import counter sum, independent of the legacy energy counter.
|
||||
* Observation timestamps are NOT proof of a billing-quarter boundary or history.
|
||||
*/
|
||||
final class NetzfahrplanV4Bezugszaehler
|
||||
{
|
||||
public static function quellen(array $sources): array
|
||||
{
|
||||
if ($sources === [] || count($sources) > 8
|
||||
|| array_keys($sources) !== range(0, count($sources) - 1)) {
|
||||
throw new InvalidArgumentException('V4 benoetigt 1 bis 8 explizite Wirkenergie-Bezugsquellen.');
|
||||
}
|
||||
$result = [];
|
||||
$parent = null;
|
||||
foreach ($sources as $source) {
|
||||
if (!is_array($source) || count($source) !== 5
|
||||
|| ($source['Messgroesse'] ?? null) !== 'WirkenergieBezug') {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Messgroesse oder Quellenschema ungueltig.');
|
||||
}
|
||||
$id = $source['VariableID'] ?? null;
|
||||
$pid = $source['ElternID'] ?? null;
|
||||
$ident = $source['Ident'] ?? null;
|
||||
$factor = $source['FaktorZuKWh'] ?? null;
|
||||
if (!is_int($id) || $id <= 0 || !is_int($pid) || $pid <= 0
|
||||
|| !is_string($ident) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $ident)
|
||||
|| (!is_int($factor) && !is_float($factor)) || !is_finite((float) $factor)
|
||||
|| $factor <= 0 || $factor > 1.0e6 || isset($result[$id])) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: ID, Ident, Einheit oder doppelte Quelle ungueltig.');
|
||||
}
|
||||
if ($parent !== null && $parent !== $pid) {
|
||||
throw new InvalidArgumentException('V4 T1/T2 muessen zum selben physischen Bezugszaehler gehoeren.');
|
||||
}
|
||||
$parent = $pid;
|
||||
foreach ($result as $existing) {
|
||||
if ($existing['Ident'] === $ident) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Register doppelt angegeben.');
|
||||
}
|
||||
}
|
||||
$result[$id] = ['VariableID' => $id, 'ElternID' => $pid, 'Ident' => $ident,
|
||||
'FaktorZuKWh' => (float) $factor, 'Messgroesse' => 'WirkenergieBezug'];
|
||||
}
|
||||
ksort($result, SORT_NUMERIC);
|
||||
return array_values($result);
|
||||
}
|
||||
|
||||
public static function identitaet(array $sources): string
|
||||
{
|
||||
// Source/factor changes invalidate old measurement evidence; names do not matter.
|
||||
return 'symcon-active-import:' . hash('sha256', json_encode(self::quellen($sources),
|
||||
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
|
||||
}
|
||||
|
||||
private static function probe(array $source, callable $read, int $now, int $maxAge): array
|
||||
{
|
||||
$m = $read($source['VariableID']);
|
||||
if (!is_array($m) || ($m['parentID'] ?? null) !== $source['ElternID']
|
||||
|| ($m['ident'] ?? null) !== $source['Ident']) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Variable passt nicht zum konfigurierten Register.');
|
||||
}
|
||||
$value = $m['value'] ?? null;
|
||||
$updated = $m['updated'] ?? null;
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < 0
|
||||
|| !is_int($updated) || $updated <= 0 || $updated > $now || $now - $updated > $maxAge) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Teilwert fehlt, ist veraltet oder ungueltig.');
|
||||
}
|
||||
$kwh = (float) $value * $source['FaktorZuKWh'];
|
||||
if (!is_finite($kwh) || $kwh > 1.0e12) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Energie ausserhalb des Messbereichs.');
|
||||
}
|
||||
return ['variableId' => $source['VariableID'], 'rawValue' => (float) $value,
|
||||
'totalKwh' => $kwh, 'updatedAt' => $updated];
|
||||
}
|
||||
|
||||
/** $read must return only value, updated, ident, parentID. Never uses an archive fallback. */
|
||||
public static function lesen(array $sources, callable $read, int $now, int $maxAge = 60, int $maxSkew = 2): array
|
||||
{
|
||||
if ($now <= 0 || $maxAge < 1 || $maxAge > 300 || $maxSkew < 0 || $maxSkew > 5) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: Zeitfenster ungueltig.');
|
||||
}
|
||||
$sources = self::quellen($sources);
|
||||
$samples = [];
|
||||
foreach ($sources as $source) {
|
||||
$samples[] = self::probe($source, $read, $now, $maxAge);
|
||||
}
|
||||
// Reread all channels to reject concurrent updates, including changes in the same second.
|
||||
foreach ($sources as $i => $source) {
|
||||
if ($samples[$i] !== self::probe($source, $read, $now, $maxAge)) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler wurde waehrend des Lesens aktualisiert.');
|
||||
}
|
||||
}
|
||||
$times = array_column($samples, 'updatedAt');
|
||||
if (max($times) - min($times) > $maxSkew) {
|
||||
throw new InvalidArgumentException('V4 Bezugszaehler: T1/T2-Zeitpunkte liegen zu weit auseinander.');
|
||||
}
|
||||
return ['meterId' => self::identitaet($sources), 'quantity' => 'active_import', 'unit' => 'kWh',
|
||||
'totalKwh' => array_sum(array_column($samples, 'totalKwh')),
|
||||
'observedAt' => gmdate('c', $now), 'sourceObservationFrom' => gmdate('c', min($times)),
|
||||
'sourceObservationUntil' => gmdate('c', max($times)), 'components' => $samples,
|
||||
'billingEvidence' => false, 'historyComplete' => false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Pure accounting candidate. No IPS, archive changes, network or control permission.
|
||||
* Battery-positive = charging. Grid-positive = import. PV-positive = production.
|
||||
* Physical storage is subtracted ONCE from house load. Virtual allocation is a
|
||||
* separate ledger, not a second physical meter. Mapping accuracy requires review.
|
||||
*/
|
||||
final class NetzfahrplanV4Bilanzierung
|
||||
{
|
||||
private static function number($v, string $name, float $min = -1.0e9, float $max = 1.0e9): float
|
||||
{
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || $v < $min || $v > $max) {
|
||||
throw new InvalidArgumentException($name . ': ungueltiger Zahlenwert.');
|
||||
}
|
||||
return (float) $v;
|
||||
}
|
||||
|
||||
private static function listArray(array $a): bool
|
||||
{
|
||||
return $a === [] || array_keys($a) === range(0, count($a)-1);
|
||||
}
|
||||
|
||||
public static function konfigurieren(array $c): array
|
||||
{
|
||||
$fields = ['version','sources','maxSkewSeconds','splitToleranceW'];
|
||||
if (count($c) !== count($fields) || array_diff($fields,array_keys($c)) || $c['version'] !== 1
|
||||
|| !is_array($c['sources']) || !self::listArray($c['sources']) || count($c['sources']) < 3 || count($c['sources']) > 80
|
||||
|| !is_int($c['maxSkewSeconds']) || $c['maxSkewSeconds'] < 0 || $c['maxSkewSeconds'] > 60) {
|
||||
throw new InvalidArgumentException('Bilanzkonfiguration unvollstaendig.');
|
||||
}
|
||||
// Preserve a stable numeric representation across JSON property round-trips.
|
||||
$c['splitToleranceW'] = self::number($c['splitToleranceW'], 'Zuordnungstoleranz W', 0, 500);
|
||||
$seen=[];$keys=[];$roles=[];
|
||||
foreach ($c['sources'] as &$s) {
|
||||
if (!is_array($s) || count($s)!==8 || array_diff(['key','role','variableId','parentId','ident','factorToW','maxAgeSeconds','dependsOn'],array_keys($s))) {
|
||||
throw new InvalidArgumentException('Explizite Quellenzuordnung erforderlich.');
|
||||
}
|
||||
if (!is_string($s['key']) || !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D',$s['key']) || isset($keys[$s['key']])
|
||||
|| !in_array($s['role'], ['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],true)
|
||||
|| !is_int($s['variableId']) || $s['variableId']<=0 || isset($seen[$s['variableId']])
|
||||
|| !is_int($s['parentId']) || $s['parentId']<=0 || !is_string($s['ident']) || strlen($s['ident'])>100
|
||||
|| !is_int($s['maxAgeSeconds']) || $s['maxAgeSeconds']<1 || $s['maxAgeSeconds']>300) {
|
||||
throw new InvalidArgumentException('Doppelte oder ungueltige Bilanzquelle.');
|
||||
}
|
||||
$s['factorToW']=self::number($s['factorToW'],'Einheiten-/Vorzeichenfaktor',-1e6,1e6);
|
||||
if ($s['factorToW']==0 || !is_array($s['dependsOn']) || !self::listArray($s['dependsOn']) || count($s['dependsOn'])>40) {
|
||||
throw new InvalidArgumentException('Abhaengigkeiten oder Faktor fehlen.');
|
||||
}
|
||||
foreach ($s['dependsOn'] as $dep) if (!is_string($dep)) throw new InvalidArgumentException('Quellenschluessel erforderlich.');
|
||||
if (count(array_unique($s['dependsOn']))!==count($s['dependsOn'])) throw new InvalidArgumentException('Doppelte Abhaengigkeit.');
|
||||
sort($s['dependsOn']);$keys[$s['key']]=true;$seen[$s['variableId']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
|
||||
}
|
||||
unset($s);
|
||||
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'],$roles['controlled_virtual'])) {
|
||||
throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.');
|
||||
}
|
||||
$graph=[];foreach ($c['sources'] as $s) $graph[$s['key']]=$s['dependsOn'];
|
||||
$visit=function($key,$stack=[]) use (&$visit,$graph): void {
|
||||
if (!array_key_exists($key,$graph) || isset($stack[$key])) throw new InvalidArgumentException('Fehlende oder zyklische Ursprungsquelle.');
|
||||
$stack[$key]=true;foreach ($graph[$key] as $dep) $visit($dep,$stack);
|
||||
};
|
||||
foreach (array_keys($graph) as $key) $visit($key);
|
||||
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));return $c;
|
||||
}
|
||||
|
||||
/** Reader: {value:number, updated:int, parentID:int, ident:string}. */
|
||||
public static function aufnehmen(array $configuration, callable $read, int $now): array
|
||||
{
|
||||
if ($now<=0) throw new InvalidArgumentException('Ungueltige Aufnahmezeit.');
|
||||
$c=self::konfigurieren($configuration);$samples=[];$sources=[];$issues=[];
|
||||
$probe=static function(array $s) use ($read): array {
|
||||
$v=$read($s['variableId']);
|
||||
if (!is_array($v) || ($v['parentID']??null)!==$s['parentId'] || ($v['ident']??null)!==$s['ident']
|
||||
|| !is_int($v['updated']??null) || $v['updated']<=0) throw new InvalidArgumentException('Quelle/Originalzeitpunkt ungueltig: '.$s['key']);
|
||||
return ['raw'=>self::number($v['value']??null,$s['key']), 'updated'=>$v['updated']];
|
||||
};
|
||||
foreach ($c['sources'] as $s) {$sources[$s['key']]=$s;$samples[$s['key']]=$probe($s);}
|
||||
foreach ($c['sources'] as $s) if ($samples[$s['key']]!==$probe($s)) throw new InvalidArgumentException('Aufnahme waehrend des Lesens geaendert: '.$s['key']);
|
||||
$origin=function($key) use (&$origin,$sources,$samples): int {
|
||||
$stamp=$samples[$key]['updated'];foreach ($sources[$key]['dependsOn'] as $dep) $stamp=min($stamp,$origin($dep));return $stamp;
|
||||
};
|
||||
$totals=array_fill_keys(['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],0.0);
|
||||
$out=[];$times=[];
|
||||
foreach ($sources as $key=>$s) {
|
||||
$t=$origin($key);$w=$samples[$key]['raw']*$s['factorToW'];
|
||||
if (!is_finite($w) || abs($w)>1e9) throw new InvalidArgumentException('Leistung ausserhalb des Bereichs.');
|
||||
if ($samples[$key]['updated']>$now || $t>$now) $issues[]='future_source:'.$key;
|
||||
if ($now-$t>$s['maxAgeSeconds']) $issues[]='stale_origin:'.$key;
|
||||
if (in_array($s['role'],['pv','flexible_load'],true) && $w<0) $issues[]='negative_unsigned_source:'.$key;
|
||||
$totals[$s['role']]+=$w;
|
||||
if ($s['role']!=='reference') $times[]=$t;
|
||||
$out[$key]=['variableId'=>$s['variableId'],'role'=>$s['role'],'powerW'=>$w,'sourceUpdatedAt'=>$samples[$key]['updated'],'oldestOriginAt'=>$t];
|
||||
}
|
||||
$skew=max($times)-min($times);if ($skew>$c['maxSkewSeconds']) $issues[]='source_time_skew';
|
||||
$load=$totals['grid']+$totals['pv']-$totals['physical_storage'];
|
||||
$base=$load-$totals['flexible_load'];
|
||||
// This channel preserves unallocated dynamics instead of hiding them in house load.
|
||||
$residual=$totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
|
||||
$external=$totals['external_virtual']+$residual;
|
||||
if ($base<0) $issues[]='negative_base_load';
|
||||
if (abs($residual)>$c['splitToleranceW']) $issues[]='virtual_physical_allocation_mismatch';
|
||||
$reconstructed=$base+$totals['flexible_load']+$totals['controlled_virtual']+$external-$totals['pv'];
|
||||
$identity=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
return ['version'=>1,'status'=>$issues===[]?'consistent_candidate':'quality_hold',
|
||||
'capturedAt'=>gmdate('c',$now),'sourceObservedFrom'=>gmdate('c',min($times)),'sourceObservedUntil'=>gmdate('c',max($times)),
|
||||
'sourceSkewSeconds'=>$skew,'mappingId'=>'accounting-map:'.$identity,
|
||||
'baseLoadCandidateW'=>$base,'consumerLoadCandidateW'=>$load,'pvW'=>$totals['pv'],'gridW'=>$totals['grid'],
|
||||
'physicalStorageW'=>$totals['physical_storage'],'controlledVirtualW'=>$totals['controlled_virtual'],
|
||||
'flexibleLoadW'=>$totals['flexible_load'],'reportedExternalW'=>$totals['external_virtual'],
|
||||
'allocationResidualW'=>$residual,'externalEffectW'=>$external,'reconstructedGridW'=>$reconstructed,
|
||||
'arithmeticBalanceErrorW'=>$reconstructed-$totals['grid'],'issues'=>$issues,'sources'=>$out,
|
||||
'controlEligible'=>false,'meteringBoundaryVerified'=>false,'forecastHistoryMigrated'=>false,
|
||||
'note'=>'Algebraic closure is not independent meter validation. No relabelling of old forecasts, no future SDL assumption.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
/** Lossless archival of fully acknowledged OLD outbox days. No device/API calls. */
|
||||
final class NetzfahrplanV4Datenarchiv
|
||||
{
|
||||
public static function run(string $directory, array $cursor, int $now): array
|
||||
{
|
||||
$day=$cursor['day']??'';
|
||||
if ($day==='') return ['archived'=>0];
|
||||
if (!is_string($day)||!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$day)
|
||||
||!is_int($cursor['offset']??null)||$cursor['offset']<0
|
||||
||is_link($directory)||realpath($directory)!==$directory) {
|
||||
throw new \RuntimeException('Invalid archive cursor/directory');
|
||||
}
|
||||
$cutoff='raw-'.gmdate('Ymd',$now-7*86400).'.jsonl';
|
||||
$archive=$directory.'/archive';
|
||||
if (is_link($archive)) throw new \RuntimeException('Archive symlink refused');
|
||||
if (!is_dir($archive)&&!mkdir($archive,0700)) throw new \RuntimeException('Archive directory unavailable');
|
||||
if ((fileperms($archive)&0007)!==0) throw new \RuntimeException('Archive must remain private');
|
||||
$lockPath=$directory.'/.writer.lock';
|
||||
if (is_link($lockPath)) throw new \RuntimeException('Writer lock symlink refused');
|
||||
$lock=fopen($lockPath,'c+b');
|
||||
if ($lock===false) throw new \RuntimeException('Archive lock unavailable');
|
||||
$done=[];
|
||||
try {
|
||||
if (!flock($lock,LOCK_EX|LOCK_NB)) return ['archived'=>0,'status'=>'writer_busy'];
|
||||
foreach (glob($directory.'/raw-*.jsonl')?:[] as $file) {
|
||||
$name=basename($file);
|
||||
if (!preg_match('/^raw-[0-9]{8}\.jsonl$/D',$name)||$name>=$day||$name>=$cutoff) continue;
|
||||
if (is_link($file)||realpath($file)!==$file||!is_file($file)) throw new \RuntimeException('Unexpected journal');
|
||||
$before=stat($file);
|
||||
if (!$before||$before['size']>67108864) throw new \RuntimeException('Archive input bounds');
|
||||
$digest=hash_file('sha256',$file);
|
||||
if (!is_string($digest)) throw new \RuntimeException('Cannot hash journal');
|
||||
$target=$archive.'/'.$name.'.'.$digest.'.gz';
|
||||
if (is_link($target)) throw new \RuntimeException('Archive target symlink');
|
||||
if (!is_file($target)) {
|
||||
$tmp=tempnam($archive,'.pending-');
|
||||
if ($tmp===false) throw new \RuntimeException('Cannot create archive');
|
||||
try {
|
||||
chmod($tmp,0600);$in=fopen($file,'rb');$gz=gzopen($tmp,'wb6');
|
||||
if ($in===false||$gz===false) {if(is_resource($in))fclose($in);if(is_resource($gz))gzclose($gz);throw new \RuntimeException('Cannot open archive streams');}
|
||||
try {
|
||||
while (!feof($in)) {
|
||||
$block=fread($in,65536);
|
||||
if ($block===false) throw new \RuntimeException('Archive read failed');
|
||||
$offset=0;
|
||||
while($offset<strlen($block)) {$n=gzwrite($gz,substr($block,$offset));if($n===false||$n===0)throw new \RuntimeException('Archive write failed');$offset+=$n;}
|
||||
}
|
||||
} finally {fclose($in);gzclose($gz);}
|
||||
$sync=fopen($tmp,'r+b');
|
||||
if ($sync===false) throw new \RuntimeException('Cannot sync archive');
|
||||
try {if(!fsync($sync))throw new \RuntimeException('Archive sync failed');} finally {fclose($sync);}
|
||||
if (!rename($tmp,$target)) throw new \RuntimeException('Archive rename failed');
|
||||
} finally {if(is_file($tmp))unlink($tmp);}
|
||||
}
|
||||
$gz=gzopen($target,'rb');if($gz===false)throw new \RuntimeException('Cannot verify archive');
|
||||
$hash=hash_init('sha256');$bytes=0;
|
||||
try {while(!gzeof($gz)){$block=gzread($gz,65536);if($block===false)throw new \RuntimeException('Archive verification read failed');$bytes+=strlen($block);if($bytes>$before['size'])throw new \RuntimeException('Oversized decompressed archive');hash_update($hash,$block);}}
|
||||
finally {gzclose($gz);}
|
||||
clearstatcache(true,$file);$after=stat($file);
|
||||
if ($bytes!==$before['size']||hash_final($hash)!==$digest||!$after
|
||||
||$after['size']!==$before['size']||$after['ino']!==$before['ino']||$after['mtime']!==$before['mtime']
|
||||
||hash_file('sha256',$file)!==$digest) throw new \RuntimeException('Archive mismatch; original retained');
|
||||
$directoryHandle=fopen($archive,'r');
|
||||
if ($directoryHandle===false) throw new \RuntimeException('Cannot sync archive directory');
|
||||
try {if(!fsync($directoryHandle))throw new \RuntimeException('Archive directory sync failed');} finally {fclose($directoryHandle);}
|
||||
if (!unlink($file)) throw new \RuntimeException('Verified original could not be retired');
|
||||
$done[]=$name;
|
||||
if (count($done)>=2) break;
|
||||
}
|
||||
} finally {flock($lock,LOCK_UN);fclose($lock);}
|
||||
return ['archived'=>count($done),'compressedOriginalsRetained'=>true,'unacknowledgedDaysTouched'=>false];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/**
|
||||
* Binds values to explicitly successful device reads.
|
||||
*
|
||||
* Transport calls, object metadata and the clock are injected so the complete
|
||||
* coordination can be tested without IP-Symcon or real devices.
|
||||
*/
|
||||
final class NetzfahrplanV4Geraeteabruf
|
||||
{
|
||||
public const MBUS_DEVICE = '{B53BE2E5-892D-4537-94AC-EAC68A469188}';
|
||||
public const MODBUS_DEVICE = '{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}';
|
||||
public const MODBUS_ADDRESS = '{CB197E50-273D-4535-8C91-BB35273E3CA5}';
|
||||
|
||||
public static function snapshot(
|
||||
array $config,
|
||||
callable $moduleId,
|
||||
callable $refresh,
|
||||
callable $reader,
|
||||
callable $clock,
|
||||
?callable $pause = null
|
||||
): array {
|
||||
$config = NetzfahrplanV4Rueckmeldung::configuration($config);
|
||||
$groups = [];
|
||||
$sourcesById = [];
|
||||
foreach ($config['sources'] as $source) {
|
||||
$sourcesById[$source['variableId']] = $source;
|
||||
if (in_array($source['role'], ['grid', 'physical'], true)) {
|
||||
$groups[$source['parentId']][] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
$snapshot = [];
|
||||
foreach ($groups as $parentId => $sources) {
|
||||
$module = $moduleId($parentId);
|
||||
if (!in_array($module, [self::MBUS_DEVICE, self::MODBUS_DEVICE, self::MODBUS_ADDRESS], true)) {
|
||||
throw new InvalidArgumentException('feedback_refresh_module_unsupported:' . $sources[0]['key']);
|
||||
}
|
||||
$confirmed = false;
|
||||
for ($attempt = 1; $attempt <= 4; ++$attempt) {
|
||||
if ($refresh($parentId, $module) === true) {
|
||||
$confirmed = true;
|
||||
break;
|
||||
}
|
||||
if ($attempt < 4 && $pause !== null) {
|
||||
$pause(200000);
|
||||
}
|
||||
}
|
||||
if (!$confirmed) {
|
||||
throw new InvalidArgumentException('feedback_device_read_failed:' . $sources[0]['key']);
|
||||
}
|
||||
$confirmedAt = $clock();
|
||||
if (!is_int($confirmedAt) || $confirmedAt < 1) {
|
||||
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
|
||||
}
|
||||
foreach ($sources as $source) {
|
||||
$record = $reader($source['variableId']);
|
||||
if (!is_array($record)) {
|
||||
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
|
||||
}
|
||||
$record['confirmedAt'] = $confirmedAt;
|
||||
$record['confirmedModuleId'] = $module;
|
||||
$snapshot[$source['variableId']] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($config['sources'] as $source) {
|
||||
if (!isset($snapshot[$source['variableId']])) {
|
||||
$record = $reader($source['variableId']);
|
||||
if (!is_array($record)) {
|
||||
throw new InvalidArgumentException('feedback_reader_invalid:' . $source['key']);
|
||||
}
|
||||
$snapshot[$source['variableId']] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($snapshot as $variableId => $record) {
|
||||
$again = $reader($variableId);
|
||||
// The variable may legitimately receive a newer process value immediately after
|
||||
// the explicit device read. Keep the confirmed snapshot, but require the source
|
||||
// identity to remain stable so a reconfigured/reparented variable is never accepted.
|
||||
$identityKeys = ['parentID', 'ident'];
|
||||
if (!is_array($again) || array_intersect_key($again, array_flip($identityKeys))
|
||||
!== array_intersect_key($record, array_flip($identityKeys))) {
|
||||
throw new InvalidArgumentException(
|
||||
'feedback_source_changed_after_confirmed_read:' . $sourcesById[$variableId]['key']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$checkedAt = $clock();
|
||||
if (!is_int($checkedAt) || $checkedAt < 1) {
|
||||
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
|
||||
}
|
||||
foreach ($snapshot as $record) {
|
||||
if (isset($record['confirmedAt']) && $record['confirmedAt'] > $checkedAt) {
|
||||
throw new InvalidArgumentException('feedback_confirmation_time_invalid');
|
||||
}
|
||||
}
|
||||
|
||||
return NetzfahrplanV4Rueckmeldung::read(
|
||||
$config,
|
||||
static fn(int $variableId): array => $snapshot[$variableId],
|
||||
$checkedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Bilanzierung.php';
|
||||
|
||||
/** Passive, versioned RAW capture. Never grants training/control eligibility.
|
||||
* Reader supplies numeric values only; no configuration, credentials or network.
|
||||
*/
|
||||
final class NetzfahrplanV4Messaufnahme
|
||||
{
|
||||
public static function configuration(array $c): array
|
||||
{
|
||||
if (($c['version'] ?? null) !== 1 || !is_string($c['installationId'] ?? null)
|
||||
|| !preg_match('/^[0-9a-f-]{36}$/D', $c['installationId'])
|
||||
|| !is_array($c['extraSources'] ?? null) || count($c['extraSources']) > 60
|
||||
|| !is_string($c['reportedInventorySha256'] ?? null)
|
||||
|| !preg_match('/^[0-9a-f]{64}$/D', $c['reportedInventorySha256'])) {
|
||||
throw new InvalidArgumentException('Invalid capture configuration');
|
||||
}
|
||||
$c['accounting'] = NetzfahrplanV4Bilanzierung::konfigurieren($c['accounting'] ?? []);
|
||||
$keys = []; $ids = [];
|
||||
foreach ($c['accounting']['sources'] as $s) { $keys[$s['key']] = true; $ids[$s['variableId']] = true; }
|
||||
foreach ($c['extraSources'] as $s) {
|
||||
if (count($s) !== 5 || !is_string($s['key'] ?? null)
|
||||
|| !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D', $s['key']) || isset($keys[$s['key']])
|
||||
|| !is_int($s['variableId'] ?? null) || $s['variableId'] < 1 || isset($ids[$s['variableId']])
|
||||
|| !is_int($s['parentId'] ?? null) || $s['parentId'] < 1
|
||||
|| !is_string($s['ident'] ?? null) || strlen($s['ident']) > 100
|
||||
|| !in_array($s['unit'] ?? null, ['W','kWh','percent','raw_power','scale'], true)) {
|
||||
throw new InvalidArgumentException('Invalid or duplicate raw source');
|
||||
}
|
||||
$keys[$s['key']] = true; $ids[$s['variableId']] = true;
|
||||
}
|
||||
usort($c['extraSources'], static fn(array $a, array $b): int => strcmp($a['key'], $b['key']));
|
||||
return $c;
|
||||
}
|
||||
|
||||
private static function readSource(array $s, callable $read): array
|
||||
{
|
||||
try {
|
||||
$r = $read($s['variableId']);
|
||||
if (!is_array($r)) throw new RuntimeException('Missing reading');
|
||||
} catch (Throwable $e) {
|
||||
return ['value' => null, 'sourceUpdatedAt' => null, 'sourceChangedAt' => null, 'issues' => ['read_failed']];
|
||||
}
|
||||
$issues = [];
|
||||
$v = $r['value'] ?? null;
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || abs((float)$v) > 1.0e12) {
|
||||
$v = null; $issues[] = 'invalid_numeric_value';
|
||||
}
|
||||
$at = $r['updated'] ?? null;
|
||||
if (!is_int($at) || $at < 1) { $at = null; $issues[] = 'missing_original_timestamp'; }
|
||||
$changed = $r['changed'] ?? null;
|
||||
if (!is_int($changed) || $changed < 0) $changed = null;
|
||||
if (($r['parentID'] ?? null) !== $s['parentId'] || ($r['ident'] ?? null) !== $s['ident']) {
|
||||
// Do not record a numeric value from an object re-used for a different purpose.
|
||||
$v = null; $issues[] = 'source_identity_mismatch';
|
||||
}
|
||||
return ['value' => $v, 'sourceUpdatedAt' => $at, 'sourceChangedAt' => $changed, 'issues' => $issues];
|
||||
}
|
||||
|
||||
public static function capture(array $config, callable $read, callable $clock): array
|
||||
{
|
||||
$c = self::configuration($config);
|
||||
$start = $clock();
|
||||
if (!is_int($start) || $start <= 0) throw new InvalidArgumentException('Integer UTC epoch required');
|
||||
$sources = array_merge($c['accounting']['sources'], $c['extraSources']);
|
||||
$first = []; $second = []; $raw = []; $issues = [];
|
||||
foreach ($sources as $s) $first[$s['key']] = self::readSource($s, $read);
|
||||
foreach ($sources as $s) $second[$s['key']] = self::readSource($s, $read);
|
||||
$end = $clock();
|
||||
if (!is_int($end) || $end < $start) throw new RuntimeException('Acquisition clock moved backwards');
|
||||
foreach ($sources as $s) {
|
||||
$key = $s['key']; $r = $first[$key];
|
||||
if ($r !== $second[$key]) {
|
||||
$r['issues'][] = 'changed_during_capture';
|
||||
$r['secondValue'] = $second[$key]['value'];
|
||||
$r['secondUpdatedAt'] = $second[$key]['sourceUpdatedAt'];
|
||||
}
|
||||
if ($r['sourceUpdatedAt'] !== null && $r['sourceUpdatedAt'] > $end) $r['issues'][] = 'future_source';
|
||||
$r['sourceAgeSeconds'] = $r['sourceUpdatedAt'] === null ? null : $end - $r['sourceUpdatedAt'];
|
||||
$raw[$key] = ['variableId' => $s['variableId']] + $r;
|
||||
foreach ($r['issues'] as $issue) $issues[] = $key . ':' . $issue;
|
||||
}
|
||||
$assessment = null; $accountingUsable = true; $samples = [];
|
||||
foreach ($c['accounting']['sources'] as $s) {
|
||||
$r = $raw[$s['key']];
|
||||
if ($r['issues'] !== []) $accountingUsable = false;
|
||||
$samples[$s['variableId']] = ['value' => $r['value'], 'updated' => $r['sourceUpdatedAt'], 'parentID' => $s['parentId'], 'ident' => $s['ident']];
|
||||
}
|
||||
if ($accountingUsable) {
|
||||
try { $assessment = NetzfahrplanV4Bilanzierung::aufnehmen($c['accounting'], static fn(int $id): array => $samples[$id], $end); }
|
||||
catch (Throwable $e) { $issues[] = 'accounting_input_invalid'; }
|
||||
} else { $issues[] = 'accounting_snapshot_unusable'; }
|
||||
if ($end - $start > 5) $issues[] = 'long_acquisition';
|
||||
return ['schemaVersion' => 1, 'kind' => 'raw_accounting_capture', 'installationId' => $c['installationId'],
|
||||
'captureStartedAt' => gmdate('c', $start), 'capturedAt' => gmdate('c', $end),
|
||||
'mappingSha256' => hash('sha256', json_encode($c, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION)),
|
||||
'reportedInventorySha256' => $c['reportedInventorySha256'],
|
||||
'timestampMeaning' => 'Symcon VariableUpdated; not proof of an independent device timestamp',
|
||||
'raw' => $raw, 'issues' => $issues, 'assessment' => $assessment,
|
||||
'trainingEligible' => false, 'controlEligible' => false, 'measurementBoundaryVerified' => false,
|
||||
'futureSdl' => 'unknown_no_assumption', 'historicalDataRewritten' => false];
|
||||
}
|
||||
|
||||
/** Append-only daily JSONL with exclusive lock, bounds and crash-tail detection.
|
||||
* No retention deletion; quota stops capture visibly, never deletes old histories.
|
||||
*/
|
||||
public static function append(string $directory, array $record, int $quota = 536870912): string
|
||||
{
|
||||
if (!is_dir($directory) || is_link($directory) || realpath($directory) !== $directory || $quota < 1) {
|
||||
throw new RuntimeException('Invalid dedicated data directory');
|
||||
}
|
||||
$json = json_encode($record, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION) . "\n";
|
||||
if (strlen($json) > 262144) throw new RuntimeException('Capture exceeds record limit');
|
||||
$epoch = strtotime($record['capturedAt'] ?? '');
|
||||
if ($epoch === false) throw new RuntimeException('Capture timestamp missing');
|
||||
$file = $directory . '/raw-' . gmdate('Ymd', $epoch) . '.jsonl';
|
||||
$lockPath = $directory . '/.writer.lock';
|
||||
if (is_link($lockPath) || is_link($file)) throw new RuntimeException('Symbolic link refused');
|
||||
$lock = fopen($lockPath, 'c+b');
|
||||
if ($lock === false) throw new RuntimeException('Capture lock unavailable');
|
||||
try {
|
||||
if (!flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('Capture writer busy');
|
||||
clearstatcache(); $used = 0; $files = glob($directory . '/raw-*.jsonl');
|
||||
if ($files === false || count($files) > 400) throw new RuntimeException('Data directory bounds exceeded');
|
||||
foreach ($files as $p) {
|
||||
if (is_link($p)) throw new RuntimeException('Symbolic journal refused');
|
||||
$size = filesize($p); if ($size === false) throw new RuntimeException('Cannot size journal');
|
||||
$used += $size;
|
||||
}
|
||||
if ($used + strlen($json) > $quota) throw new RuntimeException('Capture quota reached; no data deleted');
|
||||
$free = disk_free_space($directory);
|
||||
if ($free === false || $free < 104857600 + strlen($json)) throw new RuntimeException('Insufficient free disk; capture paused');
|
||||
$h = fopen($file, 'c+b');
|
||||
if ($h === false) throw new RuntimeException('Cannot open capture journal');
|
||||
try {
|
||||
fseek($h, 0, SEEK_END); $old = ftell($h);
|
||||
if ($old === false || $old + strlen($json) > 67108864) throw new RuntimeException('Daily capture bounds exceeded');
|
||||
if ($old > 0) {
|
||||
fseek($h, -1, SEEK_END);
|
||||
if (fread($h, 1) !== "\n") throw new RuntimeException('Incomplete journal tail; preserve for review');
|
||||
}
|
||||
fseek($h, 0, SEEK_END); $n = 0;
|
||||
try {
|
||||
while ($n < strlen($json)) {
|
||||
$written = fwrite($h, substr($json, $n));
|
||||
if ($written === false || $written === 0) throw new RuntimeException('Capture write incomplete');
|
||||
$n += $written;
|
||||
}
|
||||
if (!fflush($h)) throw new RuntimeException('Capture flush failed');
|
||||
} catch (Throwable $e) { ftruncate($h, $old); fflush($h); throw $e; }
|
||||
if (!chmod($file, 0640)) throw new RuntimeException('Cannot restrict journal permissions');
|
||||
} finally { fclose($h); }
|
||||
return $file;
|
||||
} finally { flock($lock, LOCK_UN); fclose($lock); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Independent physical-load and virtual-allocation views. Observation ONLY.
|
||||
* The unfiltered partition is an ACCOUNTING MODEL, not two measured powers.
|
||||
* No IPS calls, device writes, SOC integration, training or dispatch permission.
|
||||
*/
|
||||
final class NetzfahrplanV4Messsicht
|
||||
{
|
||||
private static function numeric($v): bool
|
||||
{
|
||||
return (is_int($v) || is_float($v)) && is_finite((float)$v) && abs($v) <= 1e12;
|
||||
}
|
||||
|
||||
public static function policy(array $p, array $c): array
|
||||
{
|
||||
$expected = ['version','evRequestKey','sdlRequestKey','extraMaxAgeSeconds','trackingToleranceW','solarReference'];
|
||||
if (count($p) !== count($expected) || array_diff($expected,array_keys($p)) || $p['version'] !== 1
|
||||
|| !is_int($p['extraMaxAgeSeconds']) || $p['extraMaxAgeSeconds'] < 1 || $p['extraMaxAgeSeconds'] > 300
|
||||
|| !self::numeric($p['trackingToleranceW']) || $p['trackingToleranceW'] < 0 || $p['trackingToleranceW'] > 1000) {
|
||||
throw new InvalidArgumentException('Invalid measurement-view policy');
|
||||
}
|
||||
$extra = array_column($c['extraSources'], null, 'key');
|
||||
foreach (['evRequestKey','sdlRequestKey'] as $k) {
|
||||
if (!is_string($p[$k]) || !isset($extra[$p[$k]]) || $extra[$p[$k]]['unit'] !== 'W') {
|
||||
throw new InvalidArgumentException('Explicit separate request channels required');
|
||||
}
|
||||
}
|
||||
if ($p['evRequestKey'] === $p['sdlRequestKey']) throw new InvalidArgumentException('Duplicate request channel');
|
||||
if ($p['solarReference'] !== null) {
|
||||
$s=$p['solarReference']; $keys=['pvKey','batteryKey','rawKey','scaleKey'];
|
||||
if (!is_array($s) || count($s)!==4 || array_diff($keys,array_keys($s))) throw new InvalidArgumentException('Invalid solar-origin policy');
|
||||
foreach ($keys as $k) if (!is_string($s[$k])) throw new InvalidArgumentException('Invalid solar-origin key');
|
||||
$primary=array_column($c['accounting']['sources'],null,'key');
|
||||
if (($primary[$s['pvKey']]['role']??null)!=='pv' || ($primary[$s['batteryKey']]['role']??null)!=='physical_storage'
|
||||
|| ($extra[$s['rawKey']]['unit']??null)!=='raw_power' || ($extra[$s['scaleKey']]['unit']??null)!=='scale') {
|
||||
throw new InvalidArgumentException('Solar references must be explicit original sources');
|
||||
}
|
||||
}
|
||||
return $p;
|
||||
}
|
||||
|
||||
public static function calculate(array $r, array $config, array $policy): array
|
||||
{
|
||||
$c=NetzfahrplanV4Messaufnahme::configuration($config);$p=self::policy($policy,$c);
|
||||
$mapping=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
if (($r['kind']??null)!=='raw_accounting_capture' || ($r['schemaVersion']??null)!==1
|
||||
|| ($r['installationId']??null)!==$c['installationId'] || ($r['mappingSha256']??null)!==$mapping
|
||||
|| ($r['reportedInventorySha256']??null)!==$c['reportedInventorySha256']) throw new InvalidArgumentException('Wrong capture identity');
|
||||
foreach (['capturedAt','captureStartedAt'] as $k) {
|
||||
if (!is_string($r[$k]??null) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$r[$k])) throw new InvalidArgumentException('Explicit UTC capture time required');
|
||||
$date=new \DateTimeImmutable($r[$k]);$err=\DateTimeImmutable::getLastErrors();
|
||||
if ($err!==false && ($err['warning_count']||$err['error_count'])) throw new InvalidArgumentException('Invalid capture time');
|
||||
$time[$k]=$date->getTimestamp();
|
||||
}
|
||||
$now=$time['capturedAt'];$duration=$now-$time['captureStartedAt'];
|
||||
if ($duration<0) throw new InvalidArgumentException('Reversed acquisition clock');
|
||||
$sources=array_column(array_merge($c['accounting']['sources'],$c['extraSources']),null,'key');
|
||||
$v=[];
|
||||
foreach ($sources as $key=>$s) {
|
||||
$raw=$r['raw'][$key]??[]; $issues=[]; $value=$raw['value']??null; $updated=$raw['sourceUpdatedAt']??null;
|
||||
if (($raw['variableId']??null)!==$s['variableId']) {$issues[]='identity';$value=null;}
|
||||
if (!self::numeric($value)) {$issues[]='not_numeric';$value=null;}
|
||||
if (!is_int($updated) || $updated<=0) {$issues[]='timestamp';$updated=null;}
|
||||
if (($raw['issues']??null)!==[]) $issues[]='capture_quality'; // never copy arbitrary source text
|
||||
if ($updated!==null && ($updated>$now || $now-$updated>($s['maxAgeSeconds']??$p['extraMaxAgeSeconds']))) $issues[]='source_age';
|
||||
$scaled=$value===null?null:(float)$value*($s['factorToW']??1.0);
|
||||
if ($scaled!==null && (!is_finite($scaled)||abs($scaled)>1e12)) {$scaled=null;$issues[]='converted_value';}
|
||||
$changed=$raw['sourceChangedAt']??null;
|
||||
if (!is_int($changed)||$changed<0||$updated===null||$changed>$updated) $changed=null;
|
||||
$v[$key]=['value'=>$scaled,'updated'=>$updated,'changed'=>$changed,'issues'=>$issues];
|
||||
}
|
||||
$closure=function(array $keys)use(&$closure,$sources):array {
|
||||
$out=[];foreach($keys as $key){if(!isset($sources[$key]))throw new InvalidArgumentException('Missing origin');$out[]=$key;
|
||||
foreach($closure($sources[$key]['dependsOn']??[]) as $dep)$out[]=$dep;}
|
||||
return array_values(array_unique($out));
|
||||
};
|
||||
$quality=function(array $keys)use($closure,$v,$c,$duration):array {
|
||||
$keys=$closure($keys);$issues=[];$ts=[];
|
||||
foreach($keys as $key){foreach($v[$key]['issues'] as $i)$issues[]=$key.':'.$i;if($v[$key]['updated']!==null)$ts[]=$v[$key]['updated'];}
|
||||
$skew=$ts===[]?null:max($ts)-min($ts);
|
||||
if($duration>5)$issues[]='long_acquisition';
|
||||
if($skew!==null && $skew>$c['accounting']['maxSkewSeconds'])$issues[]='source_time_skew';
|
||||
return ['status'=>$issues===[]?'consistent_candidate':'quality_hold','issues'=>$issues,'oldestSourceUpdatedAt'=>$ts===[]?null:min($ts),'sourceSkewSeconds'=>$skew];
|
||||
};
|
||||
$byRole=[];foreach($c['accounting']['sources'] as $s)$byRole[$s['role']][]=$s['key'];
|
||||
$physical=$byRole['physical_storage'];$pv=$byRole['pv'];$grid=$byRole['grid'];$flex=$byRole['flexible_load']??[];
|
||||
$sum=function(array $keys)use($v):?float { $total=0.0;foreach($keys as $key){if($v[$key]['value']===null)return null;$total+=$v[$key]['value'];}return $total;};
|
||||
$storage=$quality($physical);$storage['physicalStorageCandidateW']=$sum($physical);
|
||||
$solar=['status'=>'not_configured','acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null,'issues'=>['origin_not_configured']];
|
||||
$loadKeys=array_merge($grid,$pv,$physical,$flex);$sr=$p['solarReference'];
|
||||
if($sr!==null){
|
||||
$origin=[$sr['rawKey'],$sr['scaleKey'],$sr['batteryKey']];$loadKeys=array_merge($loadKeys,$origin);
|
||||
$solar=$quality($origin);$raw=$v[$sr['rawKey']]['value'];$sf=$v[$sr['scaleKey']]['value'];
|
||||
$solar+=['acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null];
|
||||
// int16 invalid sentinel excluded, including scale sentinel; no unsigned guessing.
|
||||
if($raw===null||$sf===null||floor($raw)!==$raw||$raw<=-32768||$raw>32767||floor($sf)!==$sf||$sf < -6||$sf>6){
|
||||
$solar['status']='quality_hold';$solar['issues'][]='invalid_raw_or_scale';
|
||||
}else{
|
||||
$ac=$raw*(10**(int)$sf);
|
||||
if(abs($ac)>1e9){$solar['status']='quality_hold';$solar['issues'][]='scaled_power_range';}
|
||||
else{$solar['acTerminalCandidateW']=$ac;$b=$v[$sr['batteryKey']]['value'];
|
||||
if($b!==null)$solar['reconstructedPvCandidateW']=$ac+$b;
|
||||
$reported=$v[$sr['pvKey']]['value'];
|
||||
if($reported!==null&&$solar['reconstructedPvCandidateW']!==null)$solar['derivedPvDifferenceW']=$reported-max(0.0,$solar['reconstructedPvCandidateW']);}
|
||||
}
|
||||
}
|
||||
$load=$quality($loadKeys);$net=$sum($grid);$pvSum=$sum($pv);$phys=$sum($physical);$fl=$sum($flex);
|
||||
$candidate=($net===null||$pvSum===null||$phys===null||$fl===null)?null:$net+$pvSum-$phys-$fl;
|
||||
if($candidate!==null&&$candidate<0){$load['status']='quality_hold';$load['issues'][]='negative_load';}
|
||||
foreach(array_merge($pv,$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$load['status']='quality_hold';$load['issues'][]='negative_unsigned:'.$key;}
|
||||
if($sr!==null&&$solar['status']!=='consistent_candidate'){$load['status']='quality_hold';$load['issues'][]='solar_origin_unverified_value';}
|
||||
$load+=['loadCandidateW'=>$candidate,'gridW'=>$net,'pvCandidateW'=>$pvSum,'physicalStorageCandidateW'=>$phys,'flexibleLoadW'=>$fl,
|
||||
'virtualAllocationUsed'=>false,'measurementBoundaryVerified'=>false];
|
||||
// AC terminal alternative eliminates SolarEdge battery/PV cancellation and zero-clamp artefact.
|
||||
$alternative=null;$altQuality=null;
|
||||
if($sr!==null){
|
||||
$altKeys=array_values(array_diff(array_merge($grid,$pv,$physical,$flex),[$sr['pvKey'],$sr['batteryKey']]));
|
||||
$altKeys=array_merge($altKeys,[$sr['rawKey'],$sr['scaleKey']]);$altQuality=$quality($altKeys);
|
||||
$pvRest=$sum(array_values(array_diff($pv,[$sr['pvKey']])));$bRest=$sum(array_values(array_diff($physical,[$sr['batteryKey']])));
|
||||
if($net!==null&&$pvRest!==null&&$bRest!==null&&$fl!==null&&$solar['acTerminalCandidateW']!==null){
|
||||
$alternative=$net+$pvRest-$bRest+$solar['acTerminalCandidateW']-$fl;
|
||||
if($alternative<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_load';}
|
||||
}else{$altQuality['status']='quality_hold';$altQuality['issues'][]='solar_ac_unavailable';}
|
||||
foreach(array_merge(array_values(array_diff($pv,[$sr['pvKey']])),$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_unsigned:'.$key;}
|
||||
$altQuality+=['loadCandidateW'=>$alternative,'measurementBoundaryVerified'=>false,'solarBatterySubtractedAgain'=>false];
|
||||
}
|
||||
$requestKeys=[$p['evRequestKey'],$p['sdlRequestKey']];$alloc=$quality(array_merge($physical,$requestKeys));
|
||||
$ev=$v[$p['evRequestKey']]['value'];$sdl=$v[$p['sdlRequestKey']]['value'];$evModel=null;$sdlModel=null;$unallocated=$phys;$delta=null;
|
||||
foreach($requestKeys as $key){
|
||||
if($v[$key]['changed']===null){$alloc['issues'][]='request_change_time_unknown:'.$key;$alloc['status']='quality_hold';}
|
||||
elseif($storage['oldestSourceUpdatedAt']!==null && $v[$key]['changed']>$storage['oldestSourceUpdatedAt']){$alloc['issues'][]='request_newer_than_feedback:'.$key;$alloc['status']='quality_hold';}
|
||||
}
|
||||
if($phys!==null&&$ev!==null&&$sdl!==null){
|
||||
$abs=abs($ev)+abs($sdl);$delta=$phys-$ev-$sdl;
|
||||
if($abs>=1.0){$evModel=$ev+$delta*abs($ev)/$abs;$sdlModel=$phys-$evModel;$unallocated=0.0;
|
||||
if(abs($delta)>$p['trackingToleranceW']){$alloc['issues'][]='physical_request_tracking_error';$alloc['status']='quality_hold';}
|
||||
}else{$alloc['issues'][]='idle_power_unallocated';$alloc['status']='quality_hold';}
|
||||
}
|
||||
$reportedEv=$sum($byRole['controlled_virtual']);$reportedSdl=$sum($byRole['external_virtual']??[]);
|
||||
$reportedResidual=($phys===null||$reportedEv===null||$reportedSdl===null)?null:$phys-$reportedEv-$reportedSdl;
|
||||
$alloc+=['physicalStorageCandidateW'=>$phys,'requestedEvW'=>$ev,'requestedSdlW'=>$sdl,'unfilteredModelEvW'=>$evModel,
|
||||
'unfilteredModelSdlW'=>$sdlModel,'unallocatedPhysicalW'=>$unallocated,'physicalRequestErrorW'=>$delta,
|
||||
'reportedEvW'=>$reportedEv,'reportedSdlW'=>$reportedSdl,'reportedAllocationResidualW'=>$reportedResidual,
|
||||
'reportedVirtualQuality'=>$quality(array_merge($byRole['controlled_virtual'],$byRole['external_virtual']??[])),
|
||||
'underlyingTimesMeaning'=>'Symcon VariableUpdated; not independently measured EV/SDL channel times',
|
||||
'reportedAllocationWithinTolerance'=>$reportedResidual!==null&&abs($reportedResidual)<=$c['accounting']['splitToleranceW'],
|
||||
'separatelyMeasured'=>false,'method'=>'request_weighted_residual_unfiltered_not_unique_measurement'];
|
||||
return ['schemaVersion'=>1,'kind'=>'separated_measurement_views','installationId'=>$c['installationId'],'capturedAt'=>$r['capturedAt'],
|
||||
'mappingSha256'=>$mapping,'policySha256'=>hash('sha256',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)),
|
||||
'physicalLoad'=>$load,'storageFeedback'=>$storage,'allocationModel'=>$alloc,'solarOrigin'=>$solar,'solarAcAlternative'=>$altQuality,
|
||||
'trainingEligible'=>false,'canDispatch'=>false,'controlEnabled'=>false,'socAccountsChanged'=>false,'futureSdl'=>'unknown_no_assumption',
|
||||
'note'=>'Observation only. Source times are Symcon update times. Algebraic partition is not separate EV/SDL measurement. GoodWe and combined AC/DC boundaries remain unverified.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Pure observation-only validator. No IPS calls, transport, timers or actuator writes. */
|
||||
final class NetzfahrplanV4Planpruefung
|
||||
{
|
||||
public const MAX_RESPONSE_BYTES = 2000000;
|
||||
public const MAX_PLAN_AGE = 900;
|
||||
public const MAX_RECEIPT_AGE = 90;
|
||||
|
||||
private static function liste(array $value): bool
|
||||
{
|
||||
return $value === [] || array_keys($value) === range(0, count($value) - 1);
|
||||
}
|
||||
|
||||
public static function zeit($value): int
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/D', $value)) {
|
||||
throw new InvalidArgumentException('Zeitpunkt ohne eindeutige Zeitzone.');
|
||||
}
|
||||
$time = new DateTimeImmutable($value);
|
||||
$errors = DateTimeImmutable::getLastErrors();
|
||||
if ($errors !== false && ($errors['warning_count'] || $errors['error_count'])) {
|
||||
throw new InvalidArgumentException('Ungueltiger Kalenderzeitpunkt.');
|
||||
}
|
||||
return $time->getTimestamp();
|
||||
}
|
||||
|
||||
private static function zahl($value, string $name, float $low = -1.0e9, float $high = 1.0e9): float
|
||||
{
|
||||
if ((!is_float($value) && !is_int($value)) || !is_finite((float) $value) || $value < $low || $value > $high) {
|
||||
throw new InvalidArgumentException($name . ': endlicher Zahlenwert erforderlich.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
private static function uuid($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
|
||||
throw new InvalidArgumentException('Ungueltige UUID.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function dekodieren(string $raw): array
|
||||
{
|
||||
if (strlen($raw) > self::MAX_RESPONSE_BYTES) {
|
||||
throw new InvalidArgumentException('V4-Antwort zu gross.');
|
||||
}
|
||||
$result = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($result) || self::liste($result)) {
|
||||
throw new InvalidArgumentException('V4-Antwort ist kein Objekt.');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Stable local planning policy. Volatile SOC/availability are checked separately. */
|
||||
public static function kontext(array $operation): array
|
||||
{
|
||||
$assets = [];
|
||||
foreach ($operation['batteries'] ?? [] as $b) {
|
||||
if (!is_array($b) || !is_string($b['id'] ?? null) || isset($assets[$b['id']])) {
|
||||
throw new InvalidArgumentException('Batteriezuordnung ungueltig.');
|
||||
}
|
||||
$assets[$b['id']] = [
|
||||
'capacityKwh' => self::zahl($b['capacityKwh'] ?? null, 'Kapazitaet', .001),
|
||||
'minSocPercent' => self::zahl($b['minSocPercent'] ?? null, 'Reserve', 0, 100),
|
||||
'maxSocPercent' => self::zahl($b['maxSocPercent'] ?? null, 'Maximal-SOC', 0, 100),
|
||||
'physicalMinSocPercent' => self::zahl($b['physicalMinSocPercent'] ?? 0.0, 'Minimum', 0, 100),
|
||||
'rearmSocPercent' => self::zahl($b['rearmSocPercent'] ?? $b['minSocPercent'], 'Hysterese', 0, 100),
|
||||
'gridCharging' => $b['gridCharging'] ?? null,
|
||||
];
|
||||
if (!is_bool($assets[$b['id']]['gridCharging'])) {
|
||||
throw new InvalidArgumentException('Netzladefreigabe fehlt.');
|
||||
}
|
||||
}
|
||||
if (!$assets || count($assets) > 20 || !is_array($operation['limits'] ?? null)) {
|
||||
throw new InvalidArgumentException('Batterien oder lokale Grenzen fehlen.');
|
||||
}
|
||||
ksort($assets);
|
||||
$limits = $operation['limits'];
|
||||
$expected = ['importW', 'exportW', 'managerMonthLimitsW'];
|
||||
if (count($limits) !== 3 || array_diff($expected, array_keys($limits)) || !is_array($limits['managerMonthLimitsW'])) {
|
||||
throw new InvalidArgumentException('Unvollstaendige lokale Grenzen.');
|
||||
}
|
||||
foreach (['importW', 'exportW'] as $key) {
|
||||
if ($limits[$key] !== null) $limits[$key] = self::zahl($limits[$key], 'Grenze', 0);
|
||||
}
|
||||
foreach ($limits['managerMonthLimitsW'] as $month => $limit) {
|
||||
if ((string) (int) $month !== (string) $month || (int) $month < 1 || (int) $month > 12) {
|
||||
throw new InvalidArgumentException('Ungueltige Monatsgrenze.');
|
||||
}
|
||||
$limits['managerMonthLimitsW'][$month] = self::zahl($limit, 'Monatsgrenze', 0);
|
||||
}
|
||||
ksort($limits['managerMonthLimitsW']);
|
||||
return ['batteries' => $assets, 'limits' => $limits];
|
||||
}
|
||||
|
||||
/** Authenticated endpoint must echo installation identity; old protocol is refused. */
|
||||
public static function pruefen(array $response, string $installation, array $operation, int $now, int $receivedAt): array
|
||||
{
|
||||
self::uuid($installation);
|
||||
if (($response['receiverProtocolVersion'] ?? null) !== 1
|
||||
|| ($response['installationId'] ?? null) !== $installation
|
||||
|| ($response['liveEnabled'] ?? null) !== false) {
|
||||
throw new InvalidArgumentException('V4-Protokoll/Anlagenzuordnung oder Schattenmodus ungueltig.');
|
||||
}
|
||||
if ($receivedAt > $now || $now - $receivedAt > self::MAX_RECEIPT_AGE) {
|
||||
throw new InvalidArgumentException('Empfang veraltet; Vorschau verworfen.');
|
||||
}
|
||||
$serverAt = self::zeit($response['checkedAt'] ?? null);
|
||||
if ($serverAt > $now + 5 || $now - $serverAt > self::MAX_RECEIPT_AGE) {
|
||||
throw new InvalidArgumentException('Serverantwort nicht aktuell.');
|
||||
}
|
||||
if (($response['fresh'] ?? null) !== true || ($response['pending'] ?? null) !== null) {
|
||||
throw new InvalidArgumentException('Server hat noch keinen aktuellen freigegebenen Schattenplan.');
|
||||
}
|
||||
$settings = $response['settings'] ?? [];
|
||||
$p = $response['plan'] ?? [];
|
||||
if (!is_array($p) || !is_int($settings['revision'] ?? null) || $settings['revision'] < 0
|
||||
|| ($p['schemaVersion'] ?? null) !== 2 || ($p['installationId'] ?? null) !== $installation
|
||||
|| ($p['configRevision'] ?? null) !== $settings['revision']
|
||||
|| ($settings['runMode'] ?? null) !== 'shadow'
|
||||
|| ($p['runMode'] ?? null) !== 'shadow' || ($p['liveEnabled'] ?? null) !== false
|
||||
|| ($p['executable'] ?? null) !== true
|
||||
|| !in_array($p['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
|
||||
throw new InvalidArgumentException('Planrevision, Status oder Schattenmodus ungueltig.');
|
||||
}
|
||||
self::uuid($p['planId'] ?? null);
|
||||
if (($response['lastRun']['detail']['planId'] ?? null) !== $p['planId']
|
||||
|| !in_array($response['lastRun']['status'] ?? '', ['optimal', 'feasible_time_limit'], true)) {
|
||||
throw new InvalidArgumentException('Letzte Rechnung passt nicht zum empfangenen Plan.');
|
||||
}
|
||||
$families = array_column($response['families'] ?? [], 'key');
|
||||
if (!is_string($p['sourceFamily'] ?? null) || !in_array($p['sourceFamily'], $families, true)
|
||||
|| (($settings['family'] ?? null) !== 'auto' && $settings['family'] !== $p['sourceFamily'])) {
|
||||
throw new InvalidArgumentException('Modellfamilie unbekannt oder nicht ausgewaehlt.');
|
||||
}
|
||||
$generated = self::zeit($p['generatedAt'] ?? null);
|
||||
$start = self::zeit($p['validFrom'] ?? null);
|
||||
$until = self::zeit($p['validUntil'] ?? null);
|
||||
if ($generated > $now || $now - $generated > self::MAX_PLAN_AGE || $start > $now || $until <= $now
|
||||
|| $start !== $generated || $until - $start > 48 * 3600
|
||||
|| self::zeit($p['pricesKnownUntil'] ?? null) < $until) {
|
||||
throw new InvalidArgumentException('Plan abgelaufen, zukuenftig oder ohne bekannten Preishorizont.');
|
||||
}
|
||||
$opAt = self::zeit($operation['observedAt'] ?? null);
|
||||
if ($opAt > $now || $now - $opAt > 60) {
|
||||
throw new InvalidArgumentException('Lokale Betriebsdaten veraltet.');
|
||||
}
|
||||
$context = self::kontext($operation);
|
||||
$pc = $p['controlContext'] ?? null;
|
||||
if (!is_array($pc) || count($pc) !== 2 || !is_array($pc['batteries'] ?? null) || !is_array($pc['limits'] ?? null)) {
|
||||
throw new InvalidArgumentException('Planungskontext fehlt.');
|
||||
}
|
||||
$policyAssets = [];
|
||||
foreach ($pc['batteries'] as $id => $policy) {
|
||||
if (!is_array($policy) || count($policy) !== 6) {
|
||||
throw new InvalidArgumentException('Unbekannter Batteriekontext.');
|
||||
}
|
||||
$policyAssets[] = ['id' => $id] + $policy;
|
||||
}
|
||||
// Normalize explicitly typed numeric values; strings/bools are not numbers.
|
||||
$plannedContext = self::kontext(['batteries' => $policyAssets, 'limits' => $pc['limits']]);
|
||||
if ($plannedContext != $context) {
|
||||
throw new InvalidArgumentException('Lokale Batterie-/Grenzkonfiguration passt nicht zum Plan.');
|
||||
}
|
||||
if (!is_array($p['inputRefs'] ?? null) || !is_string($p['inputRefs']['operation'] ?? null)
|
||||
|| $p['inputRefs']['operation'] === '') {
|
||||
throw new InvalidArgumentException('Plan ohne nachvollziehbare Eingangsdaten.');
|
||||
}
|
||||
self::zahl($settings['roundtripEfficiency'] ?? null, 'Wirkungsgrad', .01, 1);
|
||||
$points = $p['points'] ?? null;
|
||||
if (!is_array($points) || !self::liste($points) || count($points) < 1 || count($points) > 576) {
|
||||
throw new InvalidArgumentException('Ungueltige Zeitschrittliste.');
|
||||
}
|
||||
$current = null;
|
||||
$last = $start;
|
||||
$ids = array_keys($context['batteries']);
|
||||
foreach ($points as $point) {
|
||||
$a = self::zeit($point['time'] ?? null);
|
||||
$b = self::zeit($point['validUntil'] ?? null);
|
||||
if ($a !== $last || $b <= $a || $b - $a > 300 || $b % 300 !== 0) {
|
||||
throw new InvalidArgumentException('Luecke/Ueberlappung oder falsches Fuenfminutenraster.');
|
||||
}
|
||||
$last = $b;
|
||||
$grid = self::zahl($point['gridTargetW'] ?? null, 'Netzziel');
|
||||
$bat = self::zahl($point['batteryTargetW'] ?? null, 'Batterieziel');
|
||||
$base = self::zahl($point['baselineGridW'] ?? null, 'Basisnetz');
|
||||
$curtail = self::zahl($point['pvCurtailmentW'] ?? null, 'PV-Abregelung', 0);
|
||||
if (abs($base + $bat + $curtail - $grid) > 1.0) {
|
||||
throw new InvalidArgumentException('Leistungsbilanz des Plans verletzt.');
|
||||
}
|
||||
if (!in_array($point['intent'] ?? '', ['hold', 'gridCharge', 'pvCharge', 'discharge', 'export'], true)) {
|
||||
throw new InvalidArgumentException('Unbekannte Planabsicht.');
|
||||
}
|
||||
$targets = $point['assetTargetsW'] ?? null;
|
||||
$socs = $point['socEndPercent'] ?? null;
|
||||
if (!is_array($targets) || !is_array($socs)) {
|
||||
throw new InvalidArgumentException('Batterieziele fehlen.');
|
||||
}
|
||||
$keys = array_keys($targets);sort($keys);$socKeys = array_keys($socs);sort($socKeys);
|
||||
if ($keys !== $ids || $socKeys !== $ids) {
|
||||
throw new InvalidArgumentException('Plan enthaelt falsche Batteriezuordnung.');
|
||||
}
|
||||
$sum = 0.0;
|
||||
foreach ($targets as $id => $w) {
|
||||
$sum += self::zahl($w, 'Einzelziel');
|
||||
self::zahl($socs[$id], 'Plan-SOC', 0, 100);
|
||||
}
|
||||
if (abs($sum - $bat) > 1.0) {
|
||||
throw new InvalidArgumentException('Summe der Batterieziele passt nicht.');
|
||||
}
|
||||
foreach (['importLimitW', 'exportLimitW'] as $limit) {
|
||||
if (!array_key_exists($limit, $point)) {
|
||||
throw new InvalidArgumentException('Plangrenze fehlt.');
|
||||
}
|
||||
if ($point[$limit] !== null) {
|
||||
$v = self::zahl($point[$limit], 'Plangrenze', 0);
|
||||
if (($limit === 'importLimitW' ? $grid : -$grid) > $v + 1) {
|
||||
throw new InvalidArgumentException('Netzgrenze im Plan verletzt.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($a <= $now && $now < $b) {
|
||||
$current = $point;
|
||||
}
|
||||
}
|
||||
if ($last !== $until || $current === null) {
|
||||
throw new InvalidArgumentException('Kein derzeit gueltiger Planabschnitt.');
|
||||
}
|
||||
return ['plan' => $p, 'point' => $current, 'efficiency' => (float) $settings['roundtripEfficiency']];
|
||||
}
|
||||
|
||||
/** Proposed single-battery correction only. Never use this result as a dispatch permit. */
|
||||
public static function vorschau(array $checked, array $operation, array $actualBatteryW, int $now): array
|
||||
{
|
||||
$point = $checked['point'];$p = $checked['plan'];
|
||||
if (count($operation['batteries']) !== 1) {
|
||||
throw new InvalidArgumentException('Lokale Vorschau fuer mehrere Batterien noch nicht freigegeben.');
|
||||
}
|
||||
$b = $operation['batteries'][0];$id = $b['id'];
|
||||
$age = $now - self::zeit($b['measuredAt'] ?? null);
|
||||
if ($age < 0 || $age > 60) {
|
||||
throw new InvalidArgumentException('Batteriemessung veraltet.');
|
||||
}
|
||||
$grid = self::zahl($operation['gridW'], 'Netzleistung');
|
||||
$battery = self::zahl($actualBatteryW[$id] ?? null, 'Batterie-Istleistung');
|
||||
$soc = self::zahl($b['socPercent'], 'Aktueller SOC', $b['physicalMinSocPercent'] ?? 0, $b['maxSocPercent']);
|
||||
$remainingHours = (self::zeit($point['validUntil']) - $now) / 3600;
|
||||
if ($remainingHours <= 0) {
|
||||
throw new InvalidArgumentException('Planabschnitt abgelaufen.');
|
||||
}
|
||||
$eta = sqrt($checked['efficiency']);$capacity = self::zahl($b['capacityKwh'], 'Kapazitaet', .001);
|
||||
$charge = min(self::zahl($b['maxChargeW'], 'Ladeleistung', 0),
|
||||
max(0., $b['maxSocPercent'] - $soc) * $capacity / 100 / $eta / $remainingHours * 1000);
|
||||
$discharge = min(self::zahl($b['maxDischargeW'], 'Entladeleistung', 0),
|
||||
max(0., $soc - $b['minSocPercent']) * $capacity / 100 * $eta / $remainingHours * 1000);
|
||||
$reasons = [];
|
||||
if (($b['dischargeBlocked'] ?? false) === true || $soc <= $b['minSocPercent']) {
|
||||
$discharge = 0.0;$reasons[] = 'Lokale Entladesperre/Reserve';
|
||||
}
|
||||
$residual = $grid - $battery;
|
||||
if ($b['gridCharging'] !== true) {
|
||||
$charge = min($charge, max(0., -$residual));$reasons[] = 'Netzladen lokal gesperrt';
|
||||
}
|
||||
$target = (float) $point['gridTargetW'];$limits = $operation['limits'];
|
||||
$import = $limits['importW'];$export = $limits['exportW'];
|
||||
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
|
||||
if (isset($limits['managerMonthLimitsW'][(string) $month])) {
|
||||
$monthly = self::zahl($limits['managerMonthLimitsW'][(string) $month], 'Monatsgrenze', 0);
|
||||
$import = $import === null ? $monthly : min($import, $monthly);
|
||||
}
|
||||
if ($import !== null) $target = min($target, self::zahl($import, 'Bezugsgrenze', 0));
|
||||
if ($export !== null) $target = max($target, -self::zahl($export, 'Einspeisegrenze', 0));
|
||||
$raw = $battery + $target - $grid;
|
||||
$wanted = $raw;
|
||||
if (abs($target) > 1.0) {
|
||||
$wanted = match ($point['intent']) {
|
||||
'gridCharge' => max(0., $wanted),
|
||||
'pvCharge' => max(0., min(max(0., -$residual), $wanted)),
|
||||
'discharge', 'export' => min(0., $wanted),
|
||||
'hold' => 0.,
|
||||
};
|
||||
}
|
||||
// Hard connection limits override economic intent, never BMS/availability.
|
||||
if ($import !== null) $wanted = min($wanted, $import - $residual);
|
||||
if ($export !== null) $wanted = max($wanted, -$export - $residual);
|
||||
$clipped = min($charge, max(-$discharge, $wanted));
|
||||
if (abs($clipped - $raw) > 1) $reasons[] = 'Leistung, SOC, Planabsicht oder Netzgrenze begrenzt';
|
||||
$expectedGrid = $residual + $clipped;
|
||||
$violation = max(0., $import === null ? 0. : $expectedGrid - $import, $export === null ? 0. : -$export - $expectedGrid);
|
||||
if ($violation > 1) $reasons[] = 'Netzgrenze durch Batterie allein nicht erreichbar; lokale Schutzregelung erforderlich';
|
||||
return ['status' => 'preview_only', 'liveEnabled' => false, 'canDispatch' => false,
|
||||
'installationId' => $p['installationId'], 'planId' => $p['planId'], 'revision' => $p['configRevision'],
|
||||
'family' => $p['sourceFamily'], 'checkedAt' => gmdate('c', $now), 'step' => $point['time'],
|
||||
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
|
||||
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
|
||||
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
|
||||
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
|
||||
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
|
||||
final class NetzfahrplanV4Regeltest
|
||||
{
|
||||
public const MAX_TEST_SECONDS = 1800;
|
||||
public const MAX_ACTIVE_TEST_SECONDS = 172800;
|
||||
public const COMMAND_TTL_SECONDS = 10;
|
||||
|
||||
public static function number($value, string $name, float $min, float $max): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < $min || $value > $max) {
|
||||
throw new InvalidArgumentException($name . ': ungueltiger Wert.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
public static function uuid($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
|
||||
throw new InvalidArgumentException('Eindeutige Testkennung erforderlich.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function integer($value, string $name, int $min, int $max): int
|
||||
{
|
||||
if (!is_int($value) || $value < $min || $value > $max) {
|
||||
throw new InvalidArgumentException($name . ': Ganzzahl erforderlich.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** Validated server authorization plus independent LOCAL consent and physical mapping. */
|
||||
public static function befehl(array $response, array $operation, array $actual, array $local, int $now, int $receivedAt): array
|
||||
{
|
||||
if (($local['enabled'] ?? null) !== true || ($local['legacyEnabled'] ?? null) !== false) {
|
||||
throw new InvalidArgumentException('Keine lokale Testfreigabe oder konkurrierender Fahrplan.');
|
||||
}
|
||||
$installation = self::uuid($local['installationId'] ?? null);
|
||||
$session = self::uuid($local['sessionId'] ?? null);
|
||||
$manager = self::integer($local['managerId'] ?? null, 'Manager', 1, 99999);
|
||||
$controller = self::integer($local['batteryInstanceId'] ?? null, 'Batterieinstanz', 1, 99999);
|
||||
$activeTest = ($local['kind'] ?? null) === 'local_active_test_consent';
|
||||
$maxSeconds = $activeTest ? self::MAX_ACTIVE_TEST_SECONDS : self::MAX_TEST_SECONDS;
|
||||
$end = self::integer($local['expiresAt'] ?? null, 'Lokales Testende', $now + 1, $now + $maxSeconds);
|
||||
if (($local['lastWallClockAt'] ?? $now) > $now) throw new InvalidArgumentException('Systemzeit rueckwaerts; Test abbrechen.');
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($response, $installation, $operation, $now, $receivedAt);
|
||||
$plan = $checked['plan'];$a = $response['controlledTrial'] ?? null;
|
||||
if (!is_array($a) || ($a['version'] ?? null) !== 1 || ($a['kind'] ?? null) !== 'controlled_trial_authority'
|
||||
|| ($a['sourcePlanRemainsShadow'] ?? null) !== true || ($a['installationId'] ?? null) !== $installation
|
||||
|| ($a['sessionId'] ?? null) !== $session || ($a['managerId'] ?? null) !== $manager
|
||||
|| ($a['batteryInstanceId'] ?? null) !== $controller || ($a['sourceShadowPlanId'] ?? null) !== $plan['planId']
|
||||
|| ($a['revision'] ?? null) !== $plan['configRevision'] || ($a['family'] ?? null) !== $plan['sourceFamily']) {
|
||||
throw new InvalidArgumentException('Keine passende separate Server-Testfreigabe.');
|
||||
}
|
||||
$issued = NetzfahrplanV4Planpruefung::zeit($a['issuedAt'] ?? null);
|
||||
$expires = NetzfahrplanV4Planpruefung::zeit($a['expiresAt'] ?? null);
|
||||
$authorityAt = NetzfahrplanV4Planpruefung::zeit($a['checkedAt'] ?? null);
|
||||
$authorityUntil = NetzfahrplanV4Planpruefung::zeit($a['validUntil'] ?? null);
|
||||
if ($issued > $now || $expires <= $now || $expires - $issued > $maxSeconds
|
||||
|| $end > $expires || $authorityAt > $now + 2 || $authorityUntil <= $now
|
||||
|| $authorityUntil - $authorityAt > 90 || $authorityUntil > $expires) {
|
||||
throw new InvalidArgumentException('Testfreigabe abgelaufen oder ungueltig.');
|
||||
}
|
||||
$quality = $plan['inputQuality'] ?? [];
|
||||
$evidence = $quality['accountingEvidenceId'] ?? null;
|
||||
$watchdogBound = is_string($a['actuatorWatchdogEvidenceId'] ?? null)
|
||||
&& strlen($a['actuatorWatchdogEvidenceId']) >= 8
|
||||
&& $a['actuatorWatchdogEvidenceId'] === ($local['actuatorWatchdogEvidenceId'] ?? null);
|
||||
$watchdogWaived = $activeTest && ($local['watchdogWaived'] ?? null) === true
|
||||
&& ($a['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
|
||||
if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($evidence) || strlen($evidence) < 8
|
||||
|| $evidence !== ($a['accountingEvidenceId'] ?? null)
|
||||
|| (!$watchdogBound && !$watchdogWaived)) {
|
||||
throw new InvalidArgumentException('Anlagenbilanz oder Geraete-Watchdog nicht nachgewiesen.');
|
||||
}
|
||||
if (($plan['peakCostIsEstimate'] ?? true) && ($a['acceptEstimatedPeak'] ?? null) !== true) {
|
||||
throw new InvalidArgumentException('Geschaetzte Peakbasis nicht fuer Test akzeptiert.');
|
||||
}
|
||||
if (count($operation['batteries']) !== 1 || $operation['batteries'][0]['id'] !== ($a['assetId'] ?? null)
|
||||
|| ($local['assetId'] ?? null) !== $a['assetId']) {
|
||||
throw new InvalidArgumentException('Test braucht eindeutige Batteriezuordnung.');
|
||||
}
|
||||
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
|
||||
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
|
||||
}
|
||||
$feedback = $operation['localFeedback'][$a['assetId']] ?? [];
|
||||
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now);
|
||||
if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot');
|
||||
$otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9);
|
||||
if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot');
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
||||
$battery = $operation['batteries'][0];
|
||||
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, 1e9),
|
||||
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, 1e9),
|
||||
self::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9));
|
||||
$discharge = min(self::number($a['maxDischargeW'] ?? null, 'Server-Entladegrenze', 1, 1e9),
|
||||
self::number($local['maxDischargeW'] ?? null, 'Lokale Entladegrenze', 1, 1e9),
|
||||
self::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9));
|
||||
$watts = (int) (max(-$discharge, min($charge, $preview['previewBatteryW']))); // truncate towards zero
|
||||
$residual = $preview['actualGridW'] - $preview['actualBatteryW'];
|
||||
$grid = $residual + $watts;
|
||||
$p = $checked['point'];
|
||||
if (($p['importLimitW'] !== null && $grid > $p['importLimitW'] + 1)
|
||||
|| ($p['exportLimitW'] !== null && -$grid > $p['exportLimitW'] + 1)) {
|
||||
throw new InvalidArgumentException('Testleistungsgrenze reicht fuer Netzgrenze nicht; lokale Regelung hat Vorrang.');
|
||||
}
|
||||
return ['kind' => 'controlled_trial_command', 'version' => 1,
|
||||
'sessionId' => $session, 'installationId' => $installation, 'assetId' => $a['assetId'],
|
||||
'managerId' => $manager, 'batteryInstanceId' => $controller,
|
||||
'sequence' => self::integer($local['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX),
|
||||
'issuedAt' => $now, 'expiresAt' => min($now + self::COMMAND_TTL_SECONDS, $authorityUntil, $end,
|
||||
NetzfahrplanV4Planpruefung::zeit($p['validUntil'])),
|
||||
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
|
||||
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
|
||||
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
|
||||
'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'],
|
||||
'feedbackSourceOldestAt' => $feedback['sourceOldestAt'],
|
||||
'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'],
|
||||
'executionMeaning' => $activeTest
|
||||
? 'explicit_time_boxed_test_plant_operation'
|
||||
: 'separate_local_control_trial_not_shadow_plan_execution'];
|
||||
}
|
||||
|
||||
/** Independently checked by the battery before any physical register call. */
|
||||
public static function pruefeBatteriebefehl(array $command, array $session, int $now, float $monotonicNow): void
|
||||
{
|
||||
if (($command['kind'] ?? null) !== 'controlled_trial_command' || ($command['version'] ?? null) !== 1) {
|
||||
throw new InvalidArgumentException('Vorschau ist kein Stellbefehl.');
|
||||
}
|
||||
foreach (['sessionId', 'installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
|
||||
if (!array_key_exists($key, $session) || ($command[$key] ?? null) !== $session[$key]) {
|
||||
throw new InvalidArgumentException('Befehl gehoert nicht zur lokal angemeldeten Testsitzung.');
|
||||
}
|
||||
}
|
||||
if (($session['active'] ?? null) !== true || $now >= ($session['expiresAt'] ?? 0)
|
||||
|| $monotonicNow >= ($session['monotonicDeadline'] ?? 0)
|
||||
|| $now < ($session['lastWallClockAt'] ?? $now)) {
|
||||
throw new InvalidArgumentException('Lokale Testsitzung abgelaufen/abgebrochen.');
|
||||
}
|
||||
$sent = self::integer($command['issuedAt'] ?? null, 'Befehlszeit', $now - self::COMMAND_TTL_SECONDS, $now);
|
||||
$end = self::integer($command['expiresAt'] ?? null, 'Befehlsende', $now + 1, $sent + self::COMMAND_TTL_SECONDS);
|
||||
if ($end > $session['expiresAt']) throw new InvalidArgumentException('Befehl verlaengert Test.');
|
||||
$seq = self::integer($command['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX);
|
||||
if ($seq <= ($session['lastSequence'] ?? 0)) throw new InvalidArgumentException('Alter/doppelter Befehl verworfen.');
|
||||
$sessionCharge = self::number($session['maxChargeW'] ?? null, 'Sitzungs-Ladegrenze', 1, 1e9);
|
||||
$sessionDischarge = self::number($session['maxDischargeW'] ?? null, 'Sitzungs-Entladegrenze', 1, 1e9);
|
||||
$w = self::integer($command['watts'] ?? null, 'Sollwert W', -(int) $sessionDischarge, (int) $sessionCharge);
|
||||
$c = self::number($command['maxChargeW'] ?? null, 'Befehl-Ladegrenze', 1, $sessionCharge);
|
||||
$d = self::number($command['maxDischargeW'] ?? null, 'Befehl-Entladegrenze', 1, $sessionDischarge);
|
||||
if ($w > $c || $w < -$d) {
|
||||
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
|
||||
}
|
||||
if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash'])
|
||||
|| $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid');
|
||||
foreach (['importLimitW','exportLimitW'] as $limit) {
|
||||
if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing');
|
||||
if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9);
|
||||
}
|
||||
$l=$session['gridLimits'] ?? null;
|
||||
if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing');
|
||||
$month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n');
|
||||
$import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null;
|
||||
if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly);
|
||||
foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) {
|
||||
if ($limit!==null) {
|
||||
self::number($limit,$key,0,1e9);
|
||||
if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit');
|
||||
}
|
||||
}
|
||||
self::uuid($command['sourceShadowPlanId'] ?? null);
|
||||
}
|
||||
|
||||
/** Re-evaluated on every battery update; no reliance on the manager's earlier clipping. */
|
||||
public static function batterieZiel(array $command, array $session, array $m, array $policy, int $now, float $monotonicNow): int
|
||||
{
|
||||
if ($now >= $command['expiresAt'] || $now < $session['lastWallClockAt']
|
||||
|| $monotonicNow >= $session['commandMonotonicDeadline'] || $monotonicNow >= $session['monotonicDeadline']) {
|
||||
throw new InvalidArgumentException('Befehls-Watchdog abgelaufen.');
|
||||
}
|
||||
if (($m['Gueltig'] ?? null) !== true || ($policy['available'] ?? null) !== true) {
|
||||
throw new InvalidArgumentException('Batterie nicht verfuegbar oder Messwerte ungueltig.');
|
||||
}
|
||||
$soc = self::number($m['Ladezustand'] ?? null, 'SOC', 0, 100);
|
||||
$reserve = self::number($policy['reserve'] ?? null, 'Reserve', 0, 100);
|
||||
$minimum = self::number($policy['minimum'] ?? null, 'Minimum', 0, $reserve);
|
||||
if ($soc < $minimum) throw new InvalidArgumentException('SOC unter technischer Untergrenze.');
|
||||
$charge = min($session['maxChargeW'], $command['maxChargeW'], self::number($m['MaxLaden'] ?? null, 'Max Laden', 0, 1e9));
|
||||
$discharge = min($session['maxDischargeW'], $command['maxDischargeW'], self::number($m['MaxEntladen'] ?? null, 'Max Entladen', 0, 1e9));
|
||||
if ($soc >= 99) $charge = 0; // existing battery hardware policy is more conservative than planner max 100
|
||||
if ($soc <= $reserve || ($policy['blocked'] ?? true)) $discharge = 0;
|
||||
if (($soc <= $reserve || ($policy['blocked'] ?? true)) && $policy['rechargeLimitW'] !== null) {
|
||||
$charge = min($charge, self::number($policy['rechargeLimitW'], 'Nachladegrenze', 0, 1e9));
|
||||
}
|
||||
return (int) max(-$discharge, min($charge, $command['watts']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
|
||||
final class NetzfahrplanV4Rueckmeldung
|
||||
{
|
||||
private static function number($v, float $lo, float $hi): float
|
||||
{
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$hi) {
|
||||
throw new InvalidArgumentException('feedback_numeric_invalid');
|
||||
}
|
||||
return (float)$v;
|
||||
}
|
||||
|
||||
private static function ordered(array $v): array
|
||||
{
|
||||
if (!array_is_list($v)) ksort($v, SORT_STRING);
|
||||
foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x);
|
||||
unset($x);return $v;
|
||||
}
|
||||
|
||||
public static function configuration(array $c): array
|
||||
{
|
||||
$keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial',
|
||||
'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources'];
|
||||
if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1
|
||||
|| !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId'])
|
||||
|| !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100
|
||||
|| !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) {
|
||||
throw new InvalidArgumentException('feedback_configuration_invalid');
|
||||
}
|
||||
foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||
if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||
foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) {
|
||||
if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid');
|
||||
}
|
||||
$c['idleToleranceW']=self::number($c['idleToleranceW'],0,100);
|
||||
$c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500);
|
||||
if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid');
|
||||
$ids=[];$names=[];$roles=[];
|
||||
foreach ($c['sources'] as &$s) {
|
||||
$fields=['key','role','variableId','parentId','ident','factorToW'];
|
||||
if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s))
|
||||
|| !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key'])
|
||||
|| isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true)
|
||||
|| !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']])
|
||||
|| !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid');
|
||||
$s['factorToW']=self::number($s['factorToW'],-1e6,1e6);
|
||||
if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid');
|
||||
$ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
|
||||
}
|
||||
unset($s);
|
||||
if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required');
|
||||
foreach (['ev_request','sdl_request','gateway_active'] as $role) {
|
||||
if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required');
|
||||
}
|
||||
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));
|
||||
return self::ordered($c);
|
||||
}
|
||||
|
||||
public static function fingerprint(array $c): string
|
||||
{
|
||||
return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
}
|
||||
|
||||
/** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */
|
||||
public static function read(array $config, callable $reader, int $now): array
|
||||
{
|
||||
$c=self::configuration($config);
|
||||
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
|
||||
$first=[];$values=[];$times=[];$physicalTimes=[];$variableTimes=[];$changed=[];
|
||||
foreach ($c['sources'] as $s) {
|
||||
$r=$reader($s['variableId']);
|
||||
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|
||||
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|
||||
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']
|
||||
|| (in_array($s['role'],['grid','physical'],true)
|
||||
&& (!is_int($r['confirmedAt']??null) || $r['confirmedAt']<1 || $r['confirmedAt']>$now
|
||||
|| $r['updated']>$r['confirmedAt']))) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
|
||||
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident','confirmedAt']));
|
||||
if ($s['role']==='gateway_active') {
|
||||
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
|
||||
} else {
|
||||
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
|
||||
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
|
||||
}
|
||||
// VariableUpdated is retained as provenance. Freshness comes only from a successful device response.
|
||||
if (in_array($s['role'],['grid','physical'],true)) {
|
||||
if ($now-$r['confirmedAt']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
|
||||
$times[]=$r['confirmedAt'];$variableTimes[]=$r['updated'];
|
||||
if ($s['role']==='physical') $physicalTimes[]=$r['confirmedAt'];
|
||||
} else $changed[$s['role']]=$r['changed'];
|
||||
}
|
||||
foreach ($c['sources'] as $s) {
|
||||
$second=$reader($s['variableId']);
|
||||
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident','confirmedAt']))!==$first[$s['key']]) {
|
||||
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
|
||||
}
|
||||
}
|
||||
if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew');
|
||||
$physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null;
|
||||
if ($estimated) {
|
||||
if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed');
|
||||
$ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl;
|
||||
if ($sdl===0.0) {
|
||||
// No external commanded contribution. Physical response may lag a NEW EV command.
|
||||
$power=$physical;
|
||||
if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power');
|
||||
} else {
|
||||
if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed');
|
||||
if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking');
|
||||
$power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power;
|
||||
if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable';
|
||||
}
|
||||
if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial';
|
||||
}
|
||||
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
|
||||
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
|
||||
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
|
||||
'sourceSkewSeconds'=>max($times)-min($times),'sourceVariableOldestAt'=>min($variableTimes),
|
||||
'sourceVariableNewestAt'=>max($variableTimes),'deviceReadConfirmed'=>true,'maxAgeSeconds'=>$c['maxAgeSeconds'],
|
||||
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
|
||||
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
|
||||
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
|
||||
'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum',
|
||||
'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false];
|
||||
}
|
||||
|
||||
public static function requireTrial(array $f, array $binding, int $now): void
|
||||
{
|
||||
foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) {
|
||||
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
|
||||
}
|
||||
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|
||||
|| ($f['deviceReadConfirmed']??null)!==true
|
||||
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|
||||
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|
||||
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
|
||||
|| !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial');
|
||||
if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed');
|
||||
self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user