feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s

Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission.

Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+257
View File
@@ -0,0 +1,257 @@
<?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/ManagerNetzfahrplanV4AktivtestTrait.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 $id===200 || isset($GLOBALS['objects'][$id]); }
function IPS_GetInstance($id) {
if ($id===200) return ['InstanceStatus'=>102,'ModuleInfo'=>['ModuleID'=>'{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}']];
return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];
}
function ModBus_RequestRead($id) {
if (!empty($GLOBALS['locks']['ENELIX.V4.Battery.44234'])) throw new \LogicException('Device I/O under battery control lock');
$GLOBALS['deviceReads'] = ($GLOBALS['deviceReads'] ?? 0) + 1;
$GLOBALS['clock'] += $GLOBALS['readDelay'] ?? 0;
return $id===200 && ($GLOBALS['deviceReadOk']??true);
}
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 (($GLOBALS['actionRefusal']??null)===$key) return false;
if ($key==='NetzfahrplanV4TestStart') $GLOBALS['readDelay']=$GLOBALS['startReadDelay']??0;
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');
return true;
}
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 RegisterPropertyInteger($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 RegisterAttributeInteger($k,$v) { $this->attrs[$k]??=$v; }
private function RegisterVariableBoolean($k,$caption,$profile,$position) { $this->vars[$k]??=false; }
private function EnableAction($k) {}
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 WriteAttributeInteger($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]; }
private function SetValue($k,$v) { $this->vars[$k]=$v; }
}
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'=>39000.,'MaxEntladen'=>39000.];
$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 sample() { try { $this->aktualisiereV4Rueckmeldung(); } catch (\Throwable) {} }
public function watchdog() { $this->sample(); $this->aktualisiereIntern(true); }
public function watchdogOnly() { $this->aktualisiereIntern(true); }
public function cachedFeedback() { return $this->v4BatterieRueckmeldung(); }
public function active() { return $this->v4BatterieTestAktiv(); }
}
class ManagerHarness {
use StoreHarness, ManagerNetzfahrplanV4TestTrait, ManagerNetzfahrplanV4AktivtestTrait;
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();
$this->registriereV4ManagerAktivtest();
}
private function berechtigungLizenziert($k) { return $this->licensed; }
private function actieveDummy() {}
private function aktiveVerbraucherIDs() { return [44234]; }
private function netzfahrplanV4LokaleVorschauwerte() {
$GLOBALS['objects'][44234]->sample(); // independent acquisition timer, outside control locks
$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 {
$GLOBALS['actionRefusal']=null;$GLOBALS['readDelay']=0;$GLOBALS['startReadDelay']=0;
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];$GLOBALS['deviceReadOk']=true;
$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'=>39000.,'maxDischargeW'=>39000.,'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'=>39000.,'maxDischargeW'=>39000.];
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;}
function activeFixture(): array {
[$m,$b,$s,$r,$op,$now]=fixture();
$m->props['NetzfahrplanV4AktivtestErlaubt']=true;
$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
$b->props['NetzfahrplanV4AktivtestErlaubt']=true;
$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
return [$m,$b,$s,$r,$op,$now];
}
[$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,$r]=fixture();
$r['plan']['points'][0]['gridTargetW']=25000.;$r['plan']['points'][0]['batteryTargetW']=28000.;
$r['plan']['points'][0]['assetTargetsW']['b']=28000.;$r['plan']['points'][0]['intent']='gridCharge';
$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);
start($m,$s);tick($m);check(end($b->writes)===28000&&$m->command()['maxChargeW']===39000,'full dynamic plant power allowed above former 5kW test cap');
[$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()&&end($b->writes)===39000,'full dynamic power corrects export while hard grid limit stays enforced');
[$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');
[$m,$b]=activeFixture();$m->V4ManagerAktivtestSchalten(true);
check($m->active()&&$b->active()&&$m->vars['NetzfahrplanV4Aktivtest'],'single runtime switch starts bounded active test');
$allocation=tick($m);
check(end($b->writes)===3000&&!isset($allocation['Sollwerte'][44234]),'active test uses guarded command path');
check($m->command()['executionMeaning']==='explicit_time_boxed_test_plant_operation','active command is explicitly distinguished');
[$m2,$b2]=activeFixture();$m2->V4ManagerAktivtestSchalten(true);
$mixed=['Sollwerte'=>[44234=>999,33333=>1000]];
$m2->tick($mixed,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'calculated','Istleistung_W'=>500]]],false);
check($m2->active()&&$b2->active()&&!isset($mixed['Sollwerte'][44234])&&!isset($mixed['Sollwerte'][33333]),'active test freezes other consumers and tolerates unrelated desync');
$GLOBALS['objects']=[17004=>$m,44234=>$b];
check($m->attrs['NetzfahrplanV4AktivtestEnde']-$m->attrs['NetzfahrplanV4AktivtestBeginn']===172800,'active test has fixed 48-hour window');
advance(91);
$GLOBALS['feedbackAt']=[50001=>$clock,50002=>$clock];
$m->op['observedAt']=gmdate('c',$clock);
$m->op['batteries'][0]['measuredAt']=gmdate('c',$clock);
$envelope=json_decode($m->attrs['NetzfahrplanV4Empfang'],true);
$envelope['receivedAt']=$clock;
$envelope['response']['checkedAt']=gmdate('c',$clock);
$m->attrs['NetzfahrplanV4Empfang']=json_encode($envelope);
tick($m);
check($m->active()&&$b->active(),'local active authority refreshes while plan and measurements remain valid');
$m->V4ManagerAktivtestSchalten(false);
check(!$m->active()&&!$b->active()&&!$m->vars['NetzfahrplanV4Aktivtest']&&end($b->writes)===0,'switch off revokes session and requests zero');
[$m,$b]=activeFixture();$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active test requires explicit watchdog waiver property');
[$m,$b]=activeFixture();$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'battery independently requires explicit watchdog waiver property');
[$m,$b,$s]=fixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
rejects(fn()=>start($m,$s),'FALSE start result is rejected');
check(!$m->active()&&!$b->active(),'refused start cannot arm manager');
[$m,$b]=activeFixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'FALSE active start result is rejected');
check(!$m->vars['NetzfahrplanV4Aktivtest']&&!$m->active(),'refused active start leaves switch off');
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestCommand';tick($m);
check(!$m->active()&&!$b->active()&&$m->command()===null,'FALSE command result aborts instead of reporting sent');
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestStop';$m->V4ManagerTestStoppen();
check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'FALSE stop result stays visibly unconfirmed');
[$m,$b,$s]=fixture();$GLOBALS['startReadDelay']=1;start($m,$s);
check($m->active()&&$b->active(),'battery arming uses an independently confirmed snapshot without locked I/O');
[$m,$b,$s]=fixture();start($m,$s);$b->measurements['Gueltig']=false;tick($m);
check(str_contains(json_decode($b->GetV4BatterieTestStatus(),true)['reason'],'Messwerte ungueltig'),
'command rejection preserves underlying safety reason after zero request');
require __DIR__ . '/additional_checks.php';
require __DIR__ . '/feedback_checks.php';
require __DIR__ . '/cache_checks.php';
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
}