178 lines
16 KiB
PHP
178 lines
16 KiB
PHP
<?php
|
|
// CLI ONLY. All IPS functions and register writes below are synthetic; no live kernel/network.
|
|
namespace Belevo\EnelixEMS {
|
|
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('Offline test must NEVER run in Symcon.');
|
|
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4TestTrait.php';
|
|
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4TestTrait.php';
|
|
class Lizenzpruefung { public const NETZFAHRPLAN='grid_schedule'; }
|
|
class Nachrichtenvertrag { public const LEISTUNGSQUELLE_GEMESSEN='measured'; }
|
|
function time() { return $GLOBALS['clock']; }
|
|
function hrtime($asNumber=true) { return (int)($GLOBALS['mono']*1e9); }
|
|
function IPS_SemaphoreEnter($key,$wait) { if (!empty($GLOBALS['locks'][$key])) return false;$GLOBALS['locks'][$key]=true;return true; }
|
|
function IPS_SemaphoreLeave($key) { unset($GLOBALS['locks'][$key]); }
|
|
function IPS_InstanceExists($id) { return isset($GLOBALS['objects'][$id]); }
|
|
function IPS_GetInstance($id) { return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
|
|
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; }
|
|
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
|
|
function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); }
|
|
function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; }
|
|
function IPS_GetObject($id) { return ['ParentID'=>200,'ObjectIdent'=>'source'.$id]; }
|
|
function GetValue($id) { return $id===50001?$GLOBALS['objects'][17004]->op['gridW']:$GLOBALS['objects'][17004]->actual['b']; }
|
|
|
|
function IPS_RequestAction($id,$key,$value) {
|
|
$GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id];
|
|
if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value);
|
|
elseif ($key==='NetzfahrplanV4TestCommand') $b->V4BatterieTestBefehl($value);
|
|
elseif ($key==='NetzfahrplanV4TestStop') $b->V4BatterieTestStoppen();
|
|
else throw new \RuntimeException('Unexpected external action in offline test');
|
|
}
|
|
trait StoreHarness {
|
|
public array $props=[],$attrs=[],$buf=[],$timers=[],$vars=['Aktiv'=>true];
|
|
public bool $ready=true;
|
|
private function RegisterPropertyBoolean($k,$v) { $this->props[$k]??=$v; }
|
|
private function RegisterPropertyString($k,$v) { $this->props[$k]??=$v; }
|
|
private function RegisterAttributeString($k,$v) { $this->attrs[$k]??=$v; }
|
|
private function RegisterTimer($k,$ms,$script) { $this->timers[$k]=$ms; }
|
|
private function SetTimerInterval($k,$ms) { $this->timers[$k]=$ms; }
|
|
private function ReadPropertyBoolean($k) { return $this->props[$k]; }
|
|
private function ReadPropertyString($k) { return $this->props[$k]; }
|
|
private function ReadPropertyInteger($k) { return $this->props[$k]; }
|
|
private function ReadPropertyFloat($k) { return (float)$this->props[$k]; }
|
|
private function ReadAttributeString($k) { return $this->attrs[$k]??''; }
|
|
private function ReadAttributeInteger($k) { return $this->attrs[$k]??0; }
|
|
private function WriteAttributeString($k,$v) { $this->attrs[$k]=$v; }
|
|
private function GetBuffer($k) { return $this->buf[$k]??''; }
|
|
private function SetBuffer($k,$v) { $this->buf[$k]=$v; }
|
|
private function GetValue($k) { return $this->vars[$k]; }
|
|
}
|
|
class BatteryHarness {
|
|
use StoreHarness, BatterieNetzfahrplanV4TestTrait;
|
|
public int $InstanceID=44234;
|
|
public array $state=['HystereseAktiv'=>false],$writes=[],$measurements=[];
|
|
public bool $failRegisters=false;
|
|
public function __construct() {
|
|
$this->props=['Batteriemanagement'=>2,'ReserveLadezustand'=>15.,'MindestLadezustand'=>3.,
|
|
'NachladenMitMaximalleistung'=>true,'MaximaleNachladeleistung'=>1000,
|
|
'LadezustandVariableID'=>1001,'IstleistungVariableID'=>1002];
|
|
$this->measurements=['Gueltig'=>true,'Ladezustand'=>50.,'MaxLaden'=>5000.,'MaxEntladen'=>4000.];
|
|
$this->attrs['LetzterSollwert']=0;$this->attrs['AenderungFreigabeAb']=0;
|
|
$this->registriereV4BatterieTest();
|
|
}
|
|
private function zugeordneteManagerIDs() { return [17004]; }
|
|
private function setzeZustand($k,$v) { $this->state[$k]=$v; }
|
|
private function leseZustand($k) { return $this->state[$k]??false; }
|
|
private function schreibeRegister($w,$force) { if ($this->failRegisters) throw new \RuntimeException('synthetic register failure');$this->writes[]=$w;$this->attrs['LetzterSollwert']=$w; }
|
|
private function aktualisiereIntern($notify) { $w=$this->v4BatterieTestZiel($this->measurements,$this->vars['Aktiv'],time());if ($w!==null) $this->schreibeRegister($w,false); }
|
|
public function watchdog() { $this->aktualisiereIntern(true); }
|
|
public function active() { return $this->v4BatterieTestAktiv(); }
|
|
}
|
|
class ManagerHarness {
|
|
use StoreHarness, ManagerNetzfahrplanV4TestTrait;
|
|
public int $InstanceID=17004;
|
|
public array $op=[],$actual=['b'=>0.];public int $fallback=0;
|
|
public bool $licensed=true;
|
|
public function __construct($response,$op) {
|
|
$this->props=['NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanAktiv'=>false,
|
|
'AnlagenBatterien'=>json_encode([['ID'=>'b','SOCVariableID'=>1001,'LeistungVariableID'=>1002]])];
|
|
$this->op=$op;$this->attrs=['LizenzInstallationID'=>$response['installationId'],
|
|
'NetzfahrplanV4Empfang'=>json_encode(['response'=>$response,'receivedAt'=>time()])];
|
|
$this->registriereV4ManagerTest();
|
|
}
|
|
private function berechtigungLizenziert($k) { return $this->licensed; }
|
|
private function actieveDummy() {}
|
|
private function aktiveVerbraucherIDs() { return [44234]; }
|
|
private function netzfahrplanV4LokaleVorschauwerte() {
|
|
$op=$this->op;$f=json_decode($GLOBALS['objects'][44234]->GetV4BatterieRueckmeldung(),true);
|
|
$op['localFeedback']=['b'=>$f];return [$op,$this->actual];
|
|
}
|
|
private function regeln($renew) { $this->fallback++; }
|
|
public function tick(&$allocation,$consumers=null,$sync=true) {
|
|
$this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync);
|
|
}
|
|
public function active() { return (bool)$this->v4ManagerTestSession(); }
|
|
public function command() { return json_decode($this->attrs['NetzfahrplanV4RegeltestStatus'],true)['command']??null; }
|
|
}
|
|
}
|
|
namespace {
|
|
require_once __DIR__.'/../V4Receiver/fixture.php';
|
|
use Belevo\EnelixEMS\NetzfahrplanV4Regeltest as Gate;
|
|
use Belevo\EnelixEMS\BatteryHarness;
|
|
use Belevo\EnelixEMS\ManagerHarness;
|
|
$count=0;
|
|
function check($ok,$name) { global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n"; }
|
|
function rejects(callable $call,$name) { try {$call();}catch(Throwable $e){check(true,$name);return;}throw new RuntimeException('FAIL did not reject '.$name); }
|
|
function fixture($permissions=true,$serverAuthority=true): array {
|
|
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];
|
|
$r['plan']['inputQuality']=['loadBasis'=>'base_load','accountingEvidenceId'=>'synthetic-meter-boundary-v1'];
|
|
$r['plan']['peakCostIsEstimate']=true;
|
|
$a=['kind'=>'controlled_trial_authority','version'=>1,'sourcePlanRemainsShadow'=>true,
|
|
'sessionId'=>'20000000-0000-4000-8000-000000000001','installationId'=>$id,
|
|
'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b',
|
|
'sourceShadowPlanId'=>$r['plan']['planId'],'revision'=>1,'family'=>'3',
|
|
'issuedAt'=>gmdate('c',$now),'expiresAt'=>gmdate('c',$now+300),'checkedAt'=>gmdate('c',$now),'validUntil'=>gmdate('c',$now+90),
|
|
'maxChargeW'=>5000.,'maxDischargeW'=>5000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog',
|
|
'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']];
|
|
$r['controlledTrial']=$serverAuthority?$a:null;
|
|
$m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];$GLOBALS['feedbackAt']=[];
|
|
$fc=['version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,
|
|
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,
|
|
'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
|
|
foreach ([50001=>'grid',50002=>'physical'] as $vid=>$role) $fc['sources'][]=['key'=>'source'.$vid,'role'=>$role,'variableId'=>$vid,'parentId'=>200,'ident'=>'source'.$vid,'factorToW'=>1.0];
|
|
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($fc);
|
|
$op['localFeedback']=['b'=>json_decode($b->GetV4BatterieRueckmeldung(),true)];
|
|
|
|
$m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;
|
|
$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog';
|
|
$start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>5000.,'maxDischargeW'=>5000.];
|
|
return [$m,$b,$start,$r,$op,$now];
|
|
}
|
|
function start($m,$s) {$m->V4ManagerTestStarten(json_encode($s));}
|
|
function tick($m) {$a=['Sollwerte'=>[44234=>999]];$m->tick($a);return $a;}
|
|
function advance($s) {$GLOBALS['clock']+=$s;$GLOBALS['mono']+=$s;}
|
|
|
|
[$m,$b,$s]=fixture(false);rejects(fn()=>start($m,$s),'default local permission blocks');check($b->writes===[],'no register writes without consent');
|
|
[$m,$b,$s]=fixture(true,false);rejects(fn()=>start($m,$s),'ordinary shadow response never authorizes');check($calls===[],'no driver call for preview alone');
|
|
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RegeltestErlaubt']=false;rejects(fn()=>start($m,$s),'second battery gate independent');
|
|
[$m,$b,$s]=fixture();$m->props['NetzfahrplanAktiv']=true;rejects(fn()=>start($m,$s),'old schedule excludes new controller');
|
|
[$m,$b,$s,$r]=fixture();$r['plan']['inputQuality']['loadBasis']='house_total';$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);rejects(fn()=>start($m,$s),'unverified aggregate accounting blocks test');
|
|
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='';rejects(fn()=>start($m,$s),'unknown device watchdog blocks test');
|
|
[$m,$b,$s]=fixture();$m->ready=false;rejects(fn()=>start($m,$s),'unready manager blocks test');
|
|
[$m,$b,$s]=fixture();$s['maxChargeW']=5001;rejects(fn()=>start($m,$s),'test limit above 5kW refused');
|
|
[$m,$b,$s]=fixture();start($m,$s);check($m->active()&&$b->active(),'both sessions armed');check($b->writes===[],'arm itself sends no register command');
|
|
$allocation=tick($m);check(!isset($allocation['Sollwerte'][44234]),'one actuator owner removes ordinary target');
|
|
check(end($b->writes)===3000,'actual trait chain delivers bounded 3kW command');
|
|
$c=$m->command();check($c['kind']==='controlled_trial_command'&&$c['expiresAt']===$clock+10,'independent command type and ten-second lease');
|
|
check(json_decode($b->GetV4BatterieTestStatus(),true)['physicalResponseVerified']===false,'controller acceptance is not physical proof');
|
|
advance(2);tick($m);check($m->command()['sequence']===2,'monotonic command sequence');
|
|
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'replayed command refused');check(!$b->active()&&end($b->writes)===0,'replay aborts and releases output');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'watchdog expiry drops to zero');
|
|
[$m,$b,$s]=fixture();start($m,$s);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'missing first command expires');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$mono+=11;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'monotonic watchdog works without wall clock advance');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock-=1;$b->watchdog();check(!$b->active(),'backwards wall clock aborts');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->V4ManagerTestStoppen();check(!$m->active()&&!$b->active()&&end($b->writes)===0&&$m->fallback===1,'manual stop releases before ordinary fallback');
|
|
rejects(fn()=>start($m,$s),'stopped local session cannot resurrect');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->buf=[];$b->V4BatterieTestStoppen();check(end($b->writes)===0&&!$b->active(),'runtime restart clears persisted command marker');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Gueltig']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'invalid device measurements release command');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['MaxLaden']=1000.;$b->watchdog();check(end($b->writes)===1000,'device availability reduction rechecked');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=99.;$b->watchdog();check(end($b->writes)===0,'full battery cannot keep charging');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->props['NetzfahrplanV4RegeltestErlaubt']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'battery consent withdrawn aborts');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->props['NetzfahrplanV4RegeltestErlaubt']=false;$a=tick($m);check(!$m->active()&&isset($a['Sollwerte'][44234])&&end($b->writes)===0,'manager consent withdrawn resumes ordinary allocation');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->attrs['NetzfahrplanV4Empfang']='{}';$a=tick($m);check(!$m->active()&&end($b->writes)===0,'invalidated receiver cache ends trial');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$a=['Sollwerte'=>[44234=>123]];$m->tick($a,null,false);check(!$m->active()&&end($b->writes)===0,'unsynchronized consumers abort');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->failRegisters=true;advance(2);tick($m);check(!$m->active()&&!$b->active(),'register failure revokes both sessions');check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'stop failure not disguised as safe');
|
|
[$m,$b,$s]=fixture();start($m,$s);$a=['Sollwerte'=>[44234=>999,33333=>1000]];$m->tick($a,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'measured','Istleistung_W'=>0]]]);
|
|
check(end($b->writes)===2000&&$a['Sollwerte'][33333]===1000,'other consumer delta counted once without takeover');
|
|
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=2000.;$m->op['gridW']=-1000.;tick($m);check(end($b->writes)===3000,'command is total battery power not additive');
|
|
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=0.;$m->op['gridW']=-40000.;tick($m);check(!$m->active(),'test cap cannot override hard export limit');
|
|
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock+=91;$mono+=91;tick($m);check(!$m->active()&&end($b->writes)===0,'expired server observation cancels even with future prices');
|
|
[$m,$b,$s,$r,$op,$now]=fixture();$local=['enabled'=>true,'legacyEnabled'=>false,'installationId'=>$r['installationId'],'sessionId'=>$s['sessionId'],'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b','expiresAt'=>$now+300,'maxChargeW'=>5000,'maxDischargeW'=>5000,'sequence'=>1,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog'];
|
|
foreach (['sessionId','installationId','sourceShadowPlanId'] as $key) {
|
|
$bad=$r;$bad['controlledTrial'][$key]='ffffffff-ffff-4fff-8fff-ffffffffffff';rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'wrong authority binding '.$key);
|
|
}
|
|
$bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease');
|
|
$bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance');
|
|
require __DIR__ . '/additional_checks.php';
|
|
require __DIR__ . '/feedback_checks.php';
|
|
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
|
|
}
|