Files
Enelix-EMS/tests/V4ControlTrial/checks.php
T

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