feat(observation): separate physical load and unfiltered EV-SDL model without dispatch

This commit is contained in:
ENELIX Agent
2026-10-02 17:00:32 +00:00
parent 76f8b067cc
commit 44362e4bf5
9 changed files with 549 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../../libs/NetzfahrplanV4Messsicht.php';
require_once __DIR__.'/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
use Belevo\EnelixEMS\NetzfahrplanV4Messsicht as View;
$n=0;
function ck(bool $v,string $label):void{global $n;if(!$v)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
function refuses(callable $f,string $label):void{try{$f();}catch(Throwable $e){ck(true,$label);return;}throw new RuntimeException('Expected refusal: '.$label);}
function take($c,$p,$r,$now):array{return View::calculate(Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now),$c,$p);}
[$c,$p,$r,$now]=separatedFixture();$x=take($c,$p,$r,$now);
ck($x['physicalLoad']['loadCandidateW']===6000.,'physical sources subtracted once');
ck($x['solarAcAlternative']['loadCandidateW']===6000.,'solar AC terminal used without second battery subtraction');
ck($x['solarOrigin']['reconstructedPvCandidateW']===1000.,'original mantissa scale and battery retained');
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&$x['allocationModel']['unfilteredModelSdlW']===500.,'unfiltered partition uses physical feedback');
ck(!$x['canDispatch']&&!$x['trainingEligible']&&!$x['allocationModel']['separatelyMeasured'],'no measurement or control permission invented');
$a=$r;$a[106]['value']=-9000;$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='consistent_candidate'&&$x['physicalLoad']['loadCandidateW']===6000.,'held virtual output cannot contaminate physical load');
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&!$x['allocationModel']['reportedAllocationWithinTolerance'],'held EV value only compared not reused');
$a=$r;$a[107]['value']='never-export-this';$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='consistent_candidate','invalid SDL output does not veto physical load');
ck(!str_contains(json_encode($x),'never-export-this'),'unknown string is not propagated');
$a=$r;$a[108]['value']=42000;$a[109]['value']=-40000;$a[104]['value']=2000;$a[105]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===42000.&&$x['allocationModel']['unfilteredModelSdlW']===-40000.,'opposed commands do not divide by near-zero net');
$a=$r;$a[108]['value']=0;$a[109]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===null&&$x['allocationModel']['unallocatedPhysicalW']===3000.,'idle physical power kept unallocated not invented as EV');
$a=$r;$a[104]['value']=-3125;$a[105]['value']=0;$a[108]['value']=-10500;$a[109]['value']=0;$a[106]['value']=-8334;$a[107]['value']=0;$x=take($c,$p,$a,$now);
ck($x['allocationModel']['unfilteredModelEvW']===-3125.,'real export-example candidate is physical -3125 not held -8334');
ck($x['allocationModel']['status']==='quality_hold'&&in_array('physical_request_tracking_error',$x['allocationModel']['issues'],true),'tracking disagreement remains visible');
$a=$r;$a[108]['changed']=$now;$a[104]['updated']=$now-10;$x=take($c,$p,$a,$now);
ck(in_array('request_newer_than_feedback:req_ev',$x['allocationModel']['issues'],true),'post-command feedback not pretended');
$a=$r;unset($a[108]['changed']);$x=take($c,$p,$a,$now);
ck(in_array('request_change_time_unknown:req_ev',$x['allocationModel']['issues'],true),'unknown command change time explicit');
$a=$r;$a[105]['updated']=$now-70;$x=take($c,$p,$a,$now);
ck($x['storageFeedback']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','old physical source blocks physical feedback');
ck($x['solarAcAlternative']['status']==='consistent_candidate','AC alternative does not inherit unrelated solar battery age');
$a=$r;$a[111]['updated']=$now-70;$x=take($c,$p,$a,$now);
ck($x['solarAcAlternative']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','fresh solar derived output cannot hide old scale origin');
$a=$r;$a[110]['value']=500;$a[111]['value']=1;$a[103]['value']=6000;$x=take($c,$p,$a,$now);
ck($x['solarOrigin']['acTerminalCandidateW']===5000.&&$x['solarAcAlternative']['loadCandidateW']===11000.,'signed scale applied exactly once');
$a=$r;$a[110]['value']=-500;$a[111]['value']=0;$a[105]['value']=0;$a[103]['value']=0;$x=take($c,$p,$a,$now);
ck($x['solarOrigin']['reconstructedPvCandidateW']===-500.&&$x['solarAcAlternative']['loadCandidateW']===5500.,'negative AC not clamped or disguised as PV');
foreach([true,'0',INF,NAN,-32768]as$invalid){$a=$r;$a[111]['value']=$invalid;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'invalid scale has no numeric fallback');}
$a=$r;$a[110]['value']=-32768;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'SunSpec invalid power sentinel rejected');
$a=$r;$a[102]['updated']=$now-40;$x=take($c,$p,$a,$now);
ck($x['physicalLoad']['status']==='quality_hold'&&$x['storageFeedback']['status']==='consistent_candidate','slow PV does not veto independent storage feedback');
$a=$r;$a[108]['updated']=$now-90;$x=take($c,$p,$a,$now);ck($x['allocationModel']['status']==='quality_hold','old request timestamp visible');
$a=$r;$a[104]['updated']=$now+1;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['status']==='quality_hold','future physical value refused');
$a=$r;$a[104]['parentID']=999;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['physicalStorageCandidateW']===null,'reused measurement identity not accepted');
$a=$r;$a[101]['value']=-20000;$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']<0&&$x['physicalLoad']['status']==='quality_hold','negative load candidate retained as invalid not zero');
$a=$r;foreach($a as &$v)$v['value']=0;unset($v);$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']===0.&&$x['physicalLoad']['status']==='consistent_candidate','real zero observation accepted');
$raw=Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now);$raw['assessment']['baseLoadCandidateW']=999999.;$x=View::calculate($raw,$c,$p);ck($x['physicalLoad']['loadCandidateW']===6000.,'assessment independently recalculated');
foreach(['installationId','mappingSha256','reportedInventorySha256']as$key){$bad=$raw;$bad[$key]='wrong';refuses(fn()=>View::calculate($bad,$c,$p),'wrong identity '.$key);}
$bad=$p;$bad['sdlRequestKey']='req_ev';refuses(fn()=>View::calculate($raw,$c,$bad),'duplicate request channels refused');
$bad=$p;$bad['solarReference']['scaleKey']='soc';refuses(fn()=>View::calculate($raw,$c,$bad),'scale cannot be an arbitrary percent channel');
$before=json_encode([$raw,$c,$p]);View::calculate($raw,$c,$p);ck(json_encode([$raw,$c,$p])===$before,'inputs not mutated');
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messsicht.php');ck(!preg_match('/\b(?:IPS_|GetValue\(|SetValue\(|RequestAction\(|curl_|exec\s*\()/',$src),'pure model has no control or native calls');
echo "TOTAL $n checks passed. Synthetic observations only.\n";
+14
View File
@@ -0,0 +1,14 @@
<?php
function separatedFixture(): array
{
$now=strtotime('2026-10-02T16:00:00Z');
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,'reportedInventorySha256'=>str_repeat('a',64),
'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],'extraSources'=>[]];
$defs=[['grid','grid',3000],['pv_g','pv',5000],['pv_se','pv',1000],['bat_g','physical_storage',2000],['bat_se','physical_storage',1000],['ev','controlled_virtual',2500],['sdl','external_virtual',500]];
$r=[];
foreach($defs as $i=>[$key,$role,$value]){$id=101+$i;$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($role,['controlled_virtual','external_virtual'],true)?['bat_g','bat_se']:[]];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
foreach([['req_ev','W',2500],['req_sdl','W',500],['se_ac','raw_power',0],['se_sf','scale',0],['soc','percent',50]] as $i=>[$key,$unit,$value]){
$id=108+$i;$c['extraSources'][]=['key'=>$key,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'unit'=>$unit];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
$p=['version'=>1,'evRequestKey'=>'req_ev','sdlRequestKey'=>'req_sdl','extraMaxAgeSeconds'=>60,'trackingToleranceW'=>100.,'solarReference'=>['pvKey'=>'pv_se','batteryKey'=>'bat_se','rawKey'=>'se_ac','scaleKey'=>'se_sf']];
return [$c,$p,$r,$now];
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/fixture.php';
$n=0;
function ck($ok,$label){global $n;if(!$ok)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
function IPS_GetKernelVersion(){return '8.0';}
function IPS_InstanceExists($id){return $id===17004;}
function IPS_GetInstance($id){if($id!==17004)throw new RuntimeException('foreign instance');return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
function IPS_GetChildrenIDs($parent){return array_keys(array_filter($GLOBALS['objects'],fn($o)=>$o['ParentID']===$parent));}
function IPS_GetObject($id){return $GLOBALS['objects'][$id];}
function IPS_GetVariable($id){return $GLOBALS['variables'][$id];}
function IPS_VariableExists($id){return isset($GLOBALS['variables'][$id]);}
function GetValue($id){return $GLOBALS['values'][$id];}
function SetValue($id,$v){if($id<50000||!is_string($v))throw new RuntimeException('FORBIDDEN source/actuator write');$GLOBALS['values'][$id]=$v;return true;}
function IPS_SemaphoreEnter($name,$wait){if($GLOBALS['locks'][$name]??false)return false;$GLOBALS['locks'][$name]=true;return true;}
function IPS_SemaphoreLeave($name){$GLOBALS['locks'][$name]=false;}
function makeObject($type){$id=++$GLOBALS['next'];$GLOBALS['objects'][$id]=['ParentID'=>0,'ObjectIdent'=>'','ObjectType'=>$type,'ObjectInfo'=>''];return $id;}
function IPS_CreateCategory(){return makeObject(0);}
function IPS_CreateVariable($type){ck($type===3,'only new textual observation variables');$id=makeObject(2);$GLOBALS['variables'][$id]=['VariableType'=>3];$GLOBALS['values'][$id]='';return $id;}
function IPS_CreateScript($type){if($type!==0)throw new RuntimeException('script type');$id=makeObject(3);$GLOBALS['scripts'][$id]='';return $id;}
function changeObject($id,$key,$v){if($id<50000)throw new RuntimeException('FORBIDDEN existing object change');$GLOBALS['objects'][$id][$key]=$v;return true;}
function IPS_SetParent($id,$v){return changeObject($id,'ParentID',$v);}
function IPS_SetIdent($id,$v){return changeObject($id,'ObjectIdent',$v);}
function IPS_SetName($id,$v){return changeObject($id,'ObjectName',$v);}
function IPS_SetInfo($id,$v){return changeObject($id,'ObjectInfo',$v);}
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
function IPS_SetScriptContent($id,$s){if($id<50000)throw new RuntimeException('existing script');$GLOBALS['scripts'][$id]=$s;return true;}
function IPS_SetScriptTimer($id,$seconds){if($id<50000||!isset($GLOBALS['scripts'][$id])||!in_array($seconds,[0,30],true))throw new RuntimeException('FORBIDDEN timer change');$GLOBALS['timers'][$id]=$seconds;return true;}
[$c,$p,$r,$ignored]=separatedFixture();$now=time();$objects=[];$variables=[];$values=[];$scripts=[];$timers=[];$locks=[];$next=50000;
foreach($r as$id=>$v){$objects[$id]=['ParentID'=>$v['parentID'],'ObjectIdent'=>$v['ident'],'ObjectType'=>2,'ObjectInfo'=>'original'];$variables[$id]=['VariableType'=>is_int($v['value'])?1:2,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];$values[$id]=$v['value'];}
$sourceValues=$values;$base=sys_get_temp_dir().'/v4views-test-'.bin2hex(random_bytes(6));mkdir($base,0700);mkdir($base.'/data',0700);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php','source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php'];
foreach($files as$name){$dest=$base.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);copy(__DIR__.'/../../'.substr($name,7),$dest);}
file_put_contents($base.'/INVENTORY.json','{"effectiveEvCapacityKwh":161.44,"evPowerKw":39}');$c['reportedInventorySha256']=hash_file('sha256',$base.'/INVENTORY.json');
file_put_contents($base.'/config.json',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));file_put_contents($base.'/policy.json',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
$manifest=['scope'=>'passive_separated_observation','hashes'=>[]];foreach(array_merge($files,['config.json','policy.json','INVENTORY.json'])as$name)$manifest['hashes'][$name]=hash_file('sha256',$base.'/'.$name);file_put_contents($base.'/MANIFEST.json',json_encode($manifest));
require_once $base.'/source/examples/V4SeparatedObservation/install.php';
try{
ob_start();$report=v4viewsInstall($base);ob_end_clean();ck($report['status']==='observer_installed','native installation completes');$cat=$report['categoryId'];
ck(count($report['createdObjects'])===6,'one new category three read-only variables two scripts');
ck($timers===[$report['scriptId']=>30],'only new passive observation timer');
ck($report['firstObservation']['status']==='recorded','actual first record precedes timer');
$journal=$report['firstObservation']['journal'];$original=file_get_contents($journal);
ck((fileperms($journal)&0777)===0644&&(fileperms(dirname($journal))&0077)===0,'selected new output readable inside private directory');
foreach($sourceValues as$id=>$value)ck($values[$id]===$value,'source unchanged '.$id);
ob_start();$again=v4viewsInstall($base);ob_end_clean();ck($again['createdObjects']===[],'idempotent no duplicate scripts or variables');
ck(str_starts_with(file_get_contents($journal),$original),'repeat preserves historical observations');
$before=file_get_contents($journal);$locks['ENELIX.V4.SeparatedView.'.$c['installationId']]=true;$busy=v4viewsRun($base,$cat);ck($busy['status']==='busy'&&file_get_contents($journal)===$before,'parallel observer does not duplicate writes');$locks=[];
unset($variables[101]);$failed=v4viewsRun($base,$cat);ck($failed['status']==='recorded','missing measurement explicitly recorded');
$latest=json_decode(file_get_contents($base.'/data/LATEST.json'),true);ck($latest['views']['physicalLoad']['loadCandidateW']===null&&!$latest['controlEnabled'],'bad source never old target or fake zero');$variables[101]=['VariableType'=>1,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];
$before=file_get_contents($journal);v4viewsStop($base,$cat);ck($timers[$report['scriptId']]===0&&file_get_contents($journal)===$before,'stop only observer no deletion');
ck(json_decode(file_get_contents($base.'/data/LATEST.json'),true)['status']==='stopped','stopped view clears latest numbers');
$scripts[$report['scriptId']].='// parallel edit';ob_start();$bad=v4viewsInstall($base);ob_end_clean();ck($bad['status']==='needs_review'&&$timers[$report['scriptId']]===0,'parallel script edit not overwritten or activated');
$old=file_get_contents($base.'/config.json');file_put_contents($base.'/config.json',$old.' ');try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'changed config refused');file_put_contents($base.'/config.json',$old);
chmod($base,0755);clearstatcache();try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'public stage refused before reading output');chmod($base,0700);
foreach(['runtime.php','install.php']as$file){$text=file_get_contents($base.'/source/examples/V4SeparatedObservation/'.$file);ck(!preg_match('/\b(?:RequestAction|IPS_ApplyChanges|IPS_SetProperty|IPS_SetConfiguration|MC_Reload|curl_exec|shell_exec)\s*\(/',$text),'no actuation or configuration calls in '.$file);}
}finally{
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$f){$f->isDir()&&!$f->isLink()?rmdir($f->getPathname()):unlink($f->getPathname());}rmdir($base);
}
echo "TOTAL $n native-mock checks passed. No actual Symcon calls.\n";