$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"; }