feat(accounting): separate physical storage and SDL with source-age validation

This commit is contained in:
ENELIX Agent
2026-10-02 14:23:19 +00:00
parent 9394fe3eb1
commit e0e52d2454
4 changed files with 291 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as B;
$count=0;
function check(bool $yes,string $name): void {global $count;if(!$yes)throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
function fails(callable $f,string $name):void {try{$f();}catch(InvalidArgumentException $e){check(true,$name);return;}throw new RuntimeException('FAIL expected refusal: '.$name);}
function fixture():array {
$now=1790950000;$s=[];$m=[];
foreach(['grid'=>1000.,'pv'=>5000.,'physical_storage'=>3000.,'controlled_virtual'=>2000.,'external_virtual'=>1000.] as $role=>$value){
$id=101+count($s);$s[]=['key'=>$role,'role'=>$role,'variableId'=>$id,'parentId'=>10,'ident'=>$role,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];
$m[$id]=['value'=>$value,'updated'=>$now,'parentID'=>10,'ident'=>$role];
}
$s[3]['dependsOn']=['physical_storage'];$s[4]['dependsOn']=['physical_storage'];
return [['version'=>1,'sources'=>$s,'maxSkewSeconds'=>30,'splitToleranceW'=>100.],$m,$now];
}
function take(array $c,array $m,int $now):array{return B::aufnehmen($c,static fn($id)=>$m[$id],$now);}
[$c,$m,$n]=fixture();$r=take($c,$m,$n);
check($r['baseLoadCandidateW']===3000.,'subtract physical battery once, not virtual twice');
check($r['externalEffectW']===1000. && $r['allocationResidualW']===0.,'SDL kept outside household');
check($r['reconstructedGridW']===1000. && $r['arithmeticBalanceErrorW']===0.,'signed PCC balance closes');
check($r['controlEligible']===false && !$r['meteringBoundaryVerified'],'algebra never grants control');
check(!$r['forecastHistoryMigrated'],'old forecasts never relabelled');
$m[101]['value']=-5000.;$m[103]['value']=-3000.;$m[104]['value']=-2000.;$m[105]['value']=-1000.;$r=take($c,$m,$n);
check($r['baseLoadCandidateW']===3000. && $r['externalEffectW']===-1000.,'discharge and export signs correct');
[$c,$m,$n]=fixture();$m[103]['value']=2000.;$m[104]['value']=42000.;$m[105]['value']=-40000.;$r=take($c,$m,$n);
check($r['allocationResidualW']===0. && $r['baseLoadCandidateW']===4000.,'opposed virtual accounts are not two physical loads');
[$c,$m,$n]=fixture();$m[104]['value']=0.;$m[105]['value']=0.;$r=take($c,$m,$n);
check($r['externalEffectW']===3000. && $r['baseLoadCandidateW']===3000.,'held virtual values cannot contaminate base load');
check(in_array('virtual_physical_allocation_mismatch',$r['issues'],true),'split discrepancy explicit');
[$c,$m,$n]=fixture();$m[103]['updated']=$n-120;$r=take($c,$m,$n);
check(in_array('stale_origin:physical_storage',$r['issues'],true),'old physical reading rejected despite new acquisition');
check(in_array('stale_origin:controlled_virtual',$r['issues'],true),'fresh virtual timestamp cannot mask old physical input');
check($r['sources']['controlled_virtual']['oldestOriginAt']===$n-120,'original dependency timestamp preserved');
check($r['status']==='quality_hold','bad quality remains a diagnostic, not a training sample');
[$c,$m,$n]=fixture();$m[102]['updated']=$n-31;$r=take($c,$m,$n);check(in_array('source_time_skew',$r['issues'],true),'skew rejected separately from age');
$m[102]['updated']=$n+1;$r=take($c,$m,$n);check(in_array('future_source:pv',$r['issues'],true),'future timestamp rejected');
[$c,$m,$n]=fixture();foreach($m as &$v)$v['value']=0.;unset($v);$r=take($c,$m,$n);check($r['status']==='consistent_candidate' && $r['baseLoadCandidateW']===0.,'true all-zero observation accepted without fake history');
[$c,$m,$n]=fixture();$m[101]['value']=-6000.;$r=take($c,$m,$n);check($r['baseLoadCandidateW']===-4000. && in_array('negative_base_load',$r['issues'],true),'negative residual not silently clipped to zero');
[$c,$m,$n]=fixture();$m[102]['value']=-10.;check(in_array('negative_unsigned_source:pv',take($c,$m,$n)['issues'],true),'PV sign validity');
[$c,$m,$n]=fixture();$m[103]['value']=-3000.;$c['sources'][2]['factorToW']=-1.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'explicit vendor sign conversion');
[$c,$m,$n]=fixture();$m[101]['value']=1.;$c['sources'][0]['factorToW']=1000.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'kW to W factor applied once');
[$c,$m,$n]=fixture();$c['sources'][]=['key'=>'boiler','role'=>'flexible_load','variableId'=>200,'parentId'=>10,'ident'=>'boiler','factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];$m[200]=['value'=>500.,'updated'=>$n,'parentID'=>10,'ident'=>'boiler'];$r=take($c,$m,$n);check($r['baseLoadCandidateW']===2500. && $r['reconstructedGridW']===1000.,'future flexible load excluded exactly once');
foreach([true,'1000',NAN,INF] as $bad){[$c,$m,$n]=fixture();$m[101]['value']=$bad;fails(fn()=>take($c,$m,$n),'invalid numeric input '.gettype($bad));}
[$c,$m,$n]=fixture();$m[101]['parentID']=99;fails(fn()=>take($c,$m,$n),'different source parent rejected');
[$c,$m,$n]=fixture();$m[101]['ident']='other';fails(fn()=>take($c,$m,$n),'different source ident rejected');
[$c,$m,$n]=fixture();$c['sources'][]=$c['sources'][0];fails(fn()=>take($c,$m,$n),'duplicate physical source rejected');
[$c,$m,$n]=fixture();$c['sources'][3]['dependsOn']=['absent'];fails(fn()=>take($c,$m,$n),'missing origin rejected');
[$c,$m,$n]=fixture();$c['sources'][2]['dependsOn']=['controlled_virtual'];fails(fn()=>take($c,$m,$n),'dependency loop rejected');
[$c,$m,$n]=fixture();$a=take($c,$m,$n)['mappingId'];$c['sources']=array_reverse($c['sources']);check(take($c,$m,$n)['mappingId']===$a,'source order does not change mapping identity');
$c['sources'][0]['factorToW']=2.;check(take($c,$m,$n)['mappingId']!==$a,'factor changes invalidate old mapping identity');
[$c,$m,$n]=fixture();$calls=[];
fails(function()use($c,$m,$n,&$calls){B::aufnehmen($c,function($id)use($m,&$calls){$calls[$id]=($calls[$id]??0)+1;$v=$m[$id];if($calls[$id]>1)$v['value']++;return $v;},$n);},'same-second concurrent value change rejected');
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php');check(!preg_match('/\b(?:SetValue|IPS_RequestAction|RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$source),'adapter has no runtime or external write calls');
echo "TOTAL $count checks passed; synthetic snapshots only.\n";