feat(manager): receive and preview V4 shadow plans without dispatch

This commit is contained in:
ENELIX Agent
2026-10-02 13:01:04 +00:00
parent fc4c4632ce
commit b5c1fa6be2
9 changed files with 847 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../../libs/NetzfahrplanV4Planpruefung.php';
require_once __DIR__ . '/fixture.php';
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as P;
$count = 0;
function check($ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); $count++;echo 'PASS ' . $label . "\n"; }
function invalid(string $name, callable $change): void {
[$r,$id,$op,$now] = receiverFixture();$receipt=$now;
$change($r,$op,$now,$receipt);
try { P::pruefen($r,$id,$op,$now,$receipt); } catch (Throwable $e) { check(true,$name);return; }
check(false,$name);
}
[$r,$id,$op,$now]=receiverFixture();
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0.0],$now);
check($v['previewBatteryW']===3000.0 && $v['canDispatch']===false && $v['liveEnabled']===false,'zero grid uses actual surplus, never permission');
invalid('wrong installation',fn(&$r)=>$r['installationId']='00000000-0000-4000-8000-000000000001');
invalid('foreign plan',fn(&$r)=>$r['plan']['installationId']='00000000-0000-4000-8000-000000000001');
invalid('old protocol',function(&$r){unset($r['receiverProtocolVersion']);});
invalid('revision mismatch',fn(&$r)=>$r['plan']['configRevision']=2);
invalid('boolean revision',fn(&$r)=>$r['plan']['configRevision']=true);
invalid('live response',fn(&$r)=>$r['liveEnabled']=true);
invalid('live plan',fn(&$r)=>$r['plan']['runMode']='live');
invalid('fake executable',fn(&$r)=>$r['plan']['executable']='true');
invalid('stale server flag',fn(&$r)=>$r['fresh']=false);
invalid('pending computation',fn(&$r)=>$r['pending']=['reason'=>'new price']);
invalid('last run different plan',fn(&$r)=>$r['lastRun']['detail']['planId']='wrong');
invalid('last run invalid',fn(&$r)=>$r['lastRun']['status']='invalid_inputs');
invalid('family mismatch',fn(&$r)=>$r['settings']['family']='23');
invalid('unknown family',fn(&$r)=>$r['plan']['sourceFamily']='unregistered');
invalid('receipt stale',function(&$r,&$op,&$now,&$receipt){$receipt-=91;});
invalid('future receipt',function(&$r,&$op,&$now,&$receipt){$receipt++;});
invalid('stale server response',function(&$r)use($now){$r['checkedAt']=gmdate('c',$now-91);});
invalid('future plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now+10);});
invalid('stale plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now-901);});
invalid('price horizon too short',function(&$r)use($now){$r['plan']['pricesKnownUntil']=gmdate('c',$now+300);});
invalid('stale local operation',function(&$r,&$op)use($now){$op['observedAt']=gmdate('c',$now-61);});
invalid('changed local reserve',fn(&$r,&$op)=>$op['batteries'][0]['minSocPercent']=30.0);
invalid('changed local grid limit',fn(&$r,&$op)=>$op['limits']['importW']=2000.0);
invalid('typed context required',fn(&$r)=>$r['plan']['controlContext']['batteries']['b']['capacityKwh']='20');
invalid('input provenance missing',function(&$r){unset($r['plan']['inputRefs']);});
invalid('unknown assets',function(&$r){$r['plan']['points'][0]['assetTargetsW']=['c'=>3000];});
invalid('overlap',function(&$r){$r['plan']['points'][1]['time']=$r['plan']['points'][0]['time'];});
invalid('gap',function(&$r)use($now){$r['plan']['points'][1]['time']=gmdate('c',$now+310);});
invalid('bool target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']=false);
invalid('string target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']='0');
invalid('NaN target',fn(&$r)=>$r['plan']['points'][0]['batteryTargetW']=NAN);
invalid('broken balance',fn(&$r)=>$r['plan']['points'][0]['baselineGridW']=10000.0);
invalid('unknown intent',fn(&$r)=>$r['plan']['points'][0]['intent']='discharge_all');
invalid('no timezone',fn(&$r)=>$r['plan']['points'][0]['time']='2026-10-02T12:00:00');
invalid('bad calendar date',fn(&$r)=>$r['plan']['points'][0]['time']='2026-02-30T12:00:00Z');
invalid('missing step',fn(&$r)=>$r['plan']['points']=[]);
invalid('negative curtailment',fn(&$r)=>$r['plan']['points'][0]['pvCurtailmentW']=-100.0);
[$r,$id,$op,$now]=receiverFixture();$r['checkedAt']=$op['observedAt']=gmdate('c',$now+300);$op['batteries'][0]['measuredAt']=$op['observedAt'];
$x=P::pruefen($r,$id,$op,$now+300,$now+300);
check($x['point']['time']===gmdate('c',$now+300),'half-open boundary picks current, not interpolated');
[$r,$id,$op,$now]=receiverFixture();$r['plan']['sourceFamily']=$r['settings']['family']='future-family';$r['families'][]=['key'=>'future-family'];
check(P::pruefen($r,$id,$op,$now,$now)['plan']['sourceFamily']==='future-family','registry is extensible');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['dischargeBlocked']=true;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']===0.0,'local hysteresis prevents preview discharge');
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['socPercent']=15.001;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
check($v['previewBatteryW']>-3 && $v['previewBatteryW']<0,'remaining-step energy cannot cross reserve');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['socPercent']=90;
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.0,'full battery cannot charge');
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['maxChargeW']=0;$op['limits']['exportW']=0.;$r['plan']['controlContext']=P::kontext($op);
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['unmetGridLimitW']===3000.0 && !$v['canDispatch'],'zero export limit reports unmet constraint');
[$r,$id,$op,$now]=receiverFixture();$x=P::pruefen($r,$id,$op,$now,$now);$op['gridW']=2000;$op['batteries'][0]['gridCharging']=false;
$x['point']['gridTargetW']=8000.;$x['point']['intent']='gridCharge';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'grid charging opt-in enforced');
$op['batteries'][0]['gridCharging']=true;$x['point']['intent']='hold';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'nonzero hold does not accidentally charge');
$x['point']['gridTargetW']=0.;$x['point']['intent']='discharge';$op['gridW']=0.;$v=P::vorschau($x,$op,['b'=>-2000],$now);check($v['previewBatteryW']===-2000.,'uses actual battery feedback, not incremental zero');
try { P::dekodieren(str_repeat('x',P::MAX_RESPONSE_BYTES+1));check(false,'body limit'); } catch (InvalidArgumentException $e) {check(true,'body limit');}
echo "Total: $count checks. Synthetic data only.\n";