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
+88
View File
@@ -0,0 +1,88 @@
# Separate physical load / EV-SDL observation (NO dispatch or training permission)
## Implemented 2026-10-02
- `NetzfahrplanV4Messsicht` recalculates independent physical-load, physical-storage,
unfiltered modelled allocation and SolarEdge-origin views from explicitly identified
raw snapshots. Quality failures in held virtual accounts do not veto otherwise
consistent physical inputs. Missing/stale physical inputs still invalidate their
respective view. No automatic relaxation of age/skew limits.
- EV/SDL model: delta = physical - requestedEV - requestedSDL; distribute delta in
proportion to the absolute requested powers. The SDL component is obtained from
physical minus modelled EV to preserve numerical closure. This is an accounting
convention, NOT independently measured EV and SDL and NOT a verified response.
- Command changes more recent than the oldest physical feedback are flagged. Unknown
change timestamps, tracking mismatch, aged/missing origins and idle unallocated
power remain explicit. There is no held-value filter and no forced convergence of
displayed power to the requested value. All proposals keep `canDispatch=false`.
- Actual legacy filter outputs are displayed solely for comparison. They are not
reused as inputs of physical-load or unfiltered partition calculations. No changes
to the legacy filter, physical driver, EV/SDL setpoints, SOC integration or accounts.
## New read-only origin finding
Selected event 38921 writes legacy PV variable 20335 using:
a = 10 ** GetValue(41853) * GetValue(37975) + GetValue(21447)
if (a < 0) a = 0
SetValue(20335, a)
Arithmetic statements were inspected without exposing arbitrary scripts/credentials.
Verified saved configuration: variable 37975 parent 35514, register 40083; scale
41853 parent 48996, register 40084; battery 21447 parent 30789. An independently
updated 20335 is therefore not an independent PV reading and cannot certify all
input timestamps. A second constrained arithmetic read confirmed the condition
`if ($a < 0)`; this clamp applies to the legacy calculated PV value, not to the
new signed terminal-power observation. Neither proves the full AC/DC boundary.
The staged observer additionally records the ORIGINAL mantissa and exponent, not
only their periodically rewritten derived PV result. The alternative candidate
uses the scaled terminal power ONCE for this inverter (removing its derived-PV
and battery terms). GoodWe AC/DC boundaries and full-site physical boundary are
still unverified. This is not a definitive corrected house meter or live forecast.
## Standalone installation / operational scope
Stage on test host: `/srv/agent/netplan-v4-separated-observer-stage`.
User invocation inside Symcon only:
require '/srv/agent/netplan-v4-separated-observer-stage/install.php';
Creates only a NEW root category, three textual diagnostic variables, observe and
stop scripts and a NEW 30-second timer. Reads 23 existing numeric variables twice;
no Modbus polling requests, network calls, archive settings, existing module or
control timer updates. Existing 21-channel raw collector continues untouched.
New daily journals contain the selected numeric raw snapshot and derived views.
A new immutable mapping/inventory reference binds the corrected user EV allocation
161.44 kWh / 39 kW. Original collector metadata and its correction addendum remain
unchanged. No rescaling or resetting of virtual SOC/energy accounts.
Derived journal and LATEST/STATUS reports are 0644 WITHIN mode-0700 private stage
and data directories, allowing the authorized agent to inspect these selected
new outputs without repeated manual exports. No permissions on original journals,
Symcon directories or unrelated data are changed. Quotas reuse 64 MiB/day, 512 MiB
in total and 100 MiB disk reserve; exceeding them stops recording visibly and does
not delete old data. No network transfer or cloud trainer is activated.
## Validation and limitations
40 pure view checks + 32 native-mock checks passed on PHP 8.3 CLI at test host.
Regression: 36 raw-capture + 19 native-capture + 34 accounting checks passed with
new additive `raw_power`/`scale` units. Total 161 isolated checks. Full relevant PHP
source lint passed. Not a full PHPUnit run, Symcon kernel trial or hardware test.
Tests cover opposite EV/SDL requests, held output, non-unique partition labelling,
negative terminal power, scale/sentinel validity, old dependency timestamps, changed
commands, missing source, no duplicate native objects, unchanged existing signals,
append preservation, source drift, private output restrictions and stop behavior.
The observer is PREPARED, not started by the assistant. It does not replace the
installed receiver or its existing preview calculation. The preview/driver must
only be switched after measurement-boundary and feedback policy acceptance. Model
training and five-minute reconstruction must consume the new versioned snapshots
separately; there is no training eligibility or zero future-SDL assumption here.
Device-side command-loss protection and exact model/firmware identification remain
open. Official SolarEdge PDF fetch was unavailable (403/error); no device-specific
register suitability or watchdog behavior was inferred from a third-party source.
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/runtime.php';
function v4viewsEnsure(int $parent,string $ident,int $type,string $name,string $marker,array &$created): int
{
$id=v4viewsChild($parent,$ident);if($id!==null){v4viewsAssert($id,$parent,$ident,$type,$marker);return $id;}
$id=match($type){0=>IPS_CreateCategory(),2=>IPS_CreateVariable(3),3=>IPS_CreateScript(0)};
if(!is_int($id)||$id<=0)throw new RuntimeException('Cannot create observer object');$created[]=$id;
if(!IPS_SetParent($id,$parent)||!IPS_SetIdent($id,$ident)||!IPS_SetName($id,$name)||!IPS_SetInfo($id,$marker))throw new RuntimeException('Cannot label observer object');
return $id;
}
function v4viewsScript(int $id,string $source,array $created): void
{
if(IPS_GetScriptContent($id)===$source)return;
if(!in_array($id,$created,true))throw new RuntimeException('Existing observer script differs; not overwritten');
if(!IPS_SetScriptContent($id,$source)||IPS_GetScriptContent($id)!==$source)throw new RuntimeException('Cannot write observer script');
}
function v4viewsInstall(string $stage): array
{
if(!function_exists('IPS_GetKernelVersion')||version_compare(IPS_GetKernelVersion(),'8.0','<'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
if(!IPS_InstanceExists($c['managerId'])||IPS_GetInstance($c['managerId'])['ModuleInfo']['ModuleID']!=='{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}')throw new RuntimeException('Expected manager missing');
foreach(array_merge($c['accounting']['sources'],$c['extraSources']) as $s){
if(!IPS_VariableExists($s['variableId']))throw new RuntimeException('Expected observation source missing');
$o=IPS_GetObject($s['variableId']);$v=IPS_GetVariable($s['variableId']);
if($o['ParentID']!==$s['parentId']||$o['ObjectIdent']!==$s['ident']||!in_array($v['VariableType'],[1,2],true))throw new RuntimeException('Source identity differs');
}
if(is_link($stage.'/data')||!is_dir($stage.'/data')||!is_writable($stage.'/data')||(fileperms($stage.'/data')&0077)!==0)throw new RuntimeException('Private data directory required');
$lock='ENELIX.V4.ViewsInstall.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,3000))throw new RuntimeException('Observer installation busy');
$created=[];$script=null;$newTimer=false;$report=['scope'=>'passive_separated_observation','startedAt'=>gmdate('c'),
'oldCollectorChanged'=>false,'modulesChanged'=>false,'pollingChanged'=>false,'socAccountsChanged'=>false,'trainingEnabled'=>false,'controlEnabled'=>false];
try{
$cat=v4viewsEnsure(0,'ENELIX_V4_SEPARATED_OBSERVATION',0,'V4 - getrennte Last und EV-SDL (nur Beobachtung)',$marker,$created);
foreach(['PhysicalLoad'=>'Physische Last - Kandidat, keine Trainingsfreigabe','AllocationModel'=>'EV-SDL-Modell - KEINE separate Messung','ObservationStatus'=>'Beobachtungsstatus'] as $key=>$name)v4viewsEnsure($cat,$key,2,$name,$marker,$created);
$script=v4viewsEnsure($cat,'Observe',3,'Getrennte Messsicht alle 30 Sekunden',$marker,$created);
$stop=v4viewsEnsure($cat,'Stop',3,'Nur diese zusaetzliche Beobachtung stoppen',$marker,$created);
$include='<?php'."\n".'require_once '.var_export($stage.'/source/examples/V4SeparatedObservation/runtime.php',true).';'."\n";
v4viewsScript($script,$include.'v4viewsRun('.var_export($stage,true).', '.$cat.');'."\n",$created);
v4viewsScript($stop,$include.'v4viewsStop('.var_export($stage,true).', '.$cat.');'."\n",$created);
$first=v4viewsRun($stage,$cat);if($first['status']!=='recorded')throw new RuntimeException('First derived observation failed');
if(!IPS_SetScriptTimer($script,30))throw new RuntimeException('Cannot activate observer timer');$newTimer=true;
$report+=['status'=>'observer_installed','categoryId'=>$cat,'scriptId'=>$script,'stopId'=>$stop,'firstObservation'=>$first,'futureTimerExecutionVerified'=>false];
echo "OK: Getrennte Last-/EV-SDL-Beobachtung eingerichtet, alle 30 Sekunden.\n";
echo 'Erste Aufnahme: '.$first['capturedAt'].'; Quellen: '.$first['sourceCount'].".\n";
echo 'Physische Last: '.$first['physicalLoadStatus'].'; EV-/SDL-Modell: '.$first['allocationModelStatus'].".\n";
echo "Keine Trainings-/Stellfreigabe. Bisheriger Sammler, Manager, Gateway, SDL und Polling unveraendert.\n";
}catch(Throwable $e){
$report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,180);
if($newTimer&&$script!==null)IPS_SetScriptTimer($script,0);
echo 'FEHLER: '.$report['reason']."\n";
}finally{
$report['createdObjects']=$created;$report['finishedAt']=gmdate('c');
try{v4viewsJson($stage.'/INSTALL_RESULT.json',$report);}finally{IPS_SemaphoreLeave($lock);}
echo 'OBSERVATION REPORT: '.$stage."/INSTALL_RESULT.json\n";
}
return $report;
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** Separate read-only observations. Only newly owned objects/files may be changed. */
function v4viewsConfig(string $stage): array
{
if (!is_dir($stage) || is_link($stage) || realpath($stage)!==$stage || (fileperms($stage)&0077)!==0) throw new RuntimeException('Private observer stage required');
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,64,JSON_THROW_ON_ERROR);
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php',
'source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php','config.json','policy.json','INVENTORY.json'];
if (($m['scope']??null)!=='passive_separated_observation' || array_keys($m['hashes']??[])!==$files) throw new RuntimeException('Unexpected observer manifest');
foreach($files as $name){$p=$stage.'/'.$name;if(is_link($p)||!is_file($p)||realpath($p)!==$p||hash_file('sha256',$p)!==$m['hashes'][$name])throw new RuntimeException('Observer source drift: '.$name);}
require_once $stage.'/source/libs/NetzfahrplanV4Messsicht.php';
$c=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::configuration(json_decode((string)file_get_contents($stage.'/config.json'),true,64,JSON_THROW_ON_ERROR));
$p=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::policy(json_decode((string)file_get_contents($stage.'/policy.json'),true,64,JSON_THROW_ON_ERROR),$c);
if($c['reportedInventorySha256']!==$m['hashes']['INVENTORY.json'])throw new RuntimeException('Inventory identity differs');
return [$c,$p];
}
function v4viewsMarker(array $c): string { return 'ENELIX separated physical observer v1|'.$c['installationId']; }
function v4viewsChild(int $parent,string $ident): ?int
{
$result=null;foreach(IPS_GetChildrenIDs($parent) as $id)if(IPS_GetObject($id)['ObjectIdent']===$ident){if($result!==null)throw new RuntimeException('Duplicate observer object');$result=$id;}
return $result;
}
function v4viewsAssert(int $id,int $parent,string $ident,int $type,string $marker): void
{
$o=IPS_GetObject($id);if($o['ParentID']!==$parent||$o['ObjectIdent']!==$ident||$o['ObjectType']!==$type||$o['ObjectInfo']!==$marker)throw new RuntimeException('Observer object ownership mismatch');
if($type===2 && IPS_GetVariable($id)['VariableType']!==3)throw new RuntimeException('Observer diagnosis must be string');
}
function v4viewsRead(int $id): array
{
if(!IPS_VariableExists($id))throw new RuntimeException('Missing numeric source');
$a=IPS_GetVariable($id);$o=IPS_GetObject($id);
if(!in_array($a['VariableType'],[1,2],true))throw new RuntimeException('Non-numeric source');
return ['value'=>GetValue($id),'updated'=>(int)$a['VariableUpdated'],'changed'=>(int)$a['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
}
function v4viewsJson(string $path,array $data): void
{
if(is_link($path)||realpath(dirname($path))!==dirname($path)||(fileperms(dirname($path))&0077)!==0)throw new RuntimeException('Private observer output required');
$tmp=tempnam(dirname($path),'.views-');if($tmp===false)throw new RuntimeException('Cannot create observer report');
try{$json=json_encode($data,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT|JSON_PRESERVE_ZERO_FRACTION)."\n";
if(file_put_contents($tmp,$json,LOCK_EX)!==strlen($json)||!chmod($tmp,0644)||!rename($tmp,$path))throw new RuntimeException('Cannot store observer report');
}finally{if(is_file($tmp))unlink($tmp);}
}
function v4viewsRun(string $stage,int $category): array
{
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Run inside Symcon');
[$c,$policy]=v4viewsConfig($stage);$marker=v4viewsMarker($c);
v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$ids=[];foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){
$id=v4viewsChild($category,$key);if($id===null)throw new RuntimeException('Observer object missing');v4viewsAssert($id,$category,$key,2,$marker);$ids[$key]=$id;}
$lock='ENELIX.V4.SeparatedView.'.$c['installationId'];if(!IPS_SemaphoreEnter($lock,0))return ['status'=>'busy','controlEnabled'=>false];
try{
try{
$raw=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::capture($c,'v4viewsRead',static fn():int=>time());
$view=\Belevo\EnelixEMS\NetzfahrplanV4Messsicht::calculate($raw,$c,$policy);
$record=['schemaVersion'=>1,'kind'=>'physical_and_allocation_observation','installationId'=>$c['installationId'],'capturedAt'=>$raw['capturedAt'],
'capture'=>$raw,'views'=>$view,'trainingEligible'=>false,'controlEnabled'=>false];
$dir=$stage.'/data';if(is_link($dir)||realpath($dir)!==$dir||(fileperms($dir)&0077)!==0)throw new RuntimeException('Private data directory required');
$path=\Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme::append($dir,$record);
// Derived-only files readable to authorized agent through a mode-0700 stage/data directory.
// Original collector files and permissions are never touched.
if(!chmod($path,0644))throw new RuntimeException('Cannot expose selected derived observation');
$status=['status'=>'recorded','capturedAt'=>$raw['capturedAt'],'sourceCount'=>count($raw['raw']),'journal'=>$path,
'physicalLoadStatus'=>$view['physicalLoad']['status'],'allocationModelStatus'=>$view['allocationModel']['status'],
'solarOriginStatus'=>$view['solarOrigin']['status'],'trainingEligible'=>false,'controlEnabled'=>false];
v4viewsJson($dir.'/LATEST.json',$record);
SetValue($ids['PhysicalLoad'],json_encode(['captureAt'=>$raw['capturedAt'],'physical'=>$view['physicalLoad'],'solarAcAlternative'=>$view['solarAcAlternative'],'trainingEligible'=>false],JSON_THROW_ON_ERROR));
SetValue($ids['AllocationModel'],json_encode(['captureAt'=>$raw['capturedAt'],'model'=>$view['allocationModel'],'storage'=>$view['storageFeedback'],'canDispatch'=>false],JSON_THROW_ON_ERROR));
}catch(Throwable $e){
$status=['status'=>'observation_error','at'=>gmdate('c'),'errorType'=>get_class($e),'sourceLine'=>$e->getLine(),'trainingEligible'=>false,'controlEnabled'=>false];
// Never leave a stale numeric proposal in the new diagnosis variables.
SetValue($ids['PhysicalLoad'],json_encode($status,JSON_THROW_ON_ERROR));SetValue($ids['AllocationModel'],json_encode($status,JSON_THROW_ON_ERROR));
try{v4viewsJson($stage.'/data/LATEST.json',$status);}catch(Throwable $ignored){}
}
SetValue($ids['ObservationStatus'],json_encode($status,JSON_THROW_ON_ERROR));v4viewsJson($stage.'/data/STATUS.json',$status);return $status;
}finally{IPS_SemaphoreLeave($lock);}
}
function v4viewsStop(string $stage,int $category): void
{
[$c,$p]=v4viewsConfig($stage);$marker=v4viewsMarker($c);v4viewsAssert($category,0,'ENELIX_V4_SEPARATED_OBSERVATION',0,$marker);
$id=v4viewsChild($category,'Observe');if($id===null)throw new RuntimeException('Observer script missing');v4viewsAssert($id,$category,'Observe',3,$marker);
if(!IPS_SetScriptTimer($id,0))throw new RuntimeException('Cannot stop observer');
$s=['status'=>'stopped','at'=>gmdate('c'),'controlEnabled'=>false,'existingCollectorChanged'=>false];
foreach(['PhysicalLoad','AllocationModel','ObservationStatus'] as $key){$var=v4viewsChild($category,$key);if($var===null)throw new RuntimeException('Observer status missing');v4viewsAssert($var,$category,$key,2,$marker);SetValue($var,json_encode($s,JSON_THROW_ON_ERROR));}
v4viewsJson($stage.'/data/STATUS.json',$s);v4viewsJson($stage.'/data/LATEST.json',$s);
}
+1 -1
View File
@@ -33,7 +33,7 @@ final class NetzfahrplanV4Messaufnahme
|| !is_int($s['variableId'] ?? null) || $s['variableId'] < 1 || isset($ids[$s['variableId']])
|| !is_int($s['parentId'] ?? null) || $s['parentId'] < 1
|| !is_string($s['ident'] ?? null) || strlen($s['ident']) > 100
|| !in_array($s['unit'] ?? null, ['W','kWh','percent'], true)) {
|| !in_array($s['unit'] ?? null, ['W','kWh','percent','raw_power','scale'], true)) {
throw new InvalidArgumentException('Invalid or duplicate raw source');
}
$keys[$s['key']] = true; $ids[$s['variableId']] = true;
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
use InvalidArgumentException;
/** Independent physical-load and virtual-allocation views. Observation ONLY.
* The unfiltered partition is an ACCOUNTING MODEL, not two measured powers.
* No IPS calls, device writes, SOC integration, training or dispatch permission.
*/
final class NetzfahrplanV4Messsicht
{
private static function numeric($v): bool
{
return (is_int($v) || is_float($v)) && is_finite((float)$v) && abs($v) <= 1e12;
}
public static function policy(array $p, array $c): array
{
$expected = ['version','evRequestKey','sdlRequestKey','extraMaxAgeSeconds','trackingToleranceW','solarReference'];
if (count($p) !== count($expected) || array_diff($expected,array_keys($p)) || $p['version'] !== 1
|| !is_int($p['extraMaxAgeSeconds']) || $p['extraMaxAgeSeconds'] < 1 || $p['extraMaxAgeSeconds'] > 300
|| !self::numeric($p['trackingToleranceW']) || $p['trackingToleranceW'] < 0 || $p['trackingToleranceW'] > 1000) {
throw new InvalidArgumentException('Invalid measurement-view policy');
}
$extra = array_column($c['extraSources'], null, 'key');
foreach (['evRequestKey','sdlRequestKey'] as $k) {
if (!is_string($p[$k]) || !isset($extra[$p[$k]]) || $extra[$p[$k]]['unit'] !== 'W') {
throw new InvalidArgumentException('Explicit separate request channels required');
}
}
if ($p['evRequestKey'] === $p['sdlRequestKey']) throw new InvalidArgumentException('Duplicate request channel');
if ($p['solarReference'] !== null) {
$s=$p['solarReference']; $keys=['pvKey','batteryKey','rawKey','scaleKey'];
if (!is_array($s) || count($s)!==4 || array_diff($keys,array_keys($s))) throw new InvalidArgumentException('Invalid solar-origin policy');
foreach ($keys as $k) if (!is_string($s[$k])) throw new InvalidArgumentException('Invalid solar-origin key');
$primary=array_column($c['accounting']['sources'],null,'key');
if (($primary[$s['pvKey']]['role']??null)!=='pv' || ($primary[$s['batteryKey']]['role']??null)!=='physical_storage'
|| ($extra[$s['rawKey']]['unit']??null)!=='raw_power' || ($extra[$s['scaleKey']]['unit']??null)!=='scale') {
throw new InvalidArgumentException('Solar references must be explicit original sources');
}
}
return $p;
}
public static function calculate(array $r, array $config, array $policy): array
{
$c=NetzfahrplanV4Messaufnahme::configuration($config);$p=self::policy($policy,$c);
$mapping=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
if (($r['kind']??null)!=='raw_accounting_capture' || ($r['schemaVersion']??null)!==1
|| ($r['installationId']??null)!==$c['installationId'] || ($r['mappingSha256']??null)!==$mapping
|| ($r['reportedInventorySha256']??null)!==$c['reportedInventorySha256']) throw new InvalidArgumentException('Wrong capture identity');
foreach (['capturedAt','captureStartedAt'] as $k) {
if (!is_string($r[$k]??null) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$r[$k])) throw new InvalidArgumentException('Explicit UTC capture time required');
$date=new \DateTimeImmutable($r[$k]);$err=\DateTimeImmutable::getLastErrors();
if ($err!==false && ($err['warning_count']||$err['error_count'])) throw new InvalidArgumentException('Invalid capture time');
$time[$k]=$date->getTimestamp();
}
$now=$time['capturedAt'];$duration=$now-$time['captureStartedAt'];
if ($duration<0) throw new InvalidArgumentException('Reversed acquisition clock');
$sources=array_column(array_merge($c['accounting']['sources'],$c['extraSources']),null,'key');
$v=[];
foreach ($sources as $key=>$s) {
$raw=$r['raw'][$key]??[]; $issues=[]; $value=$raw['value']??null; $updated=$raw['sourceUpdatedAt']??null;
if (($raw['variableId']??null)!==$s['variableId']) {$issues[]='identity';$value=null;}
if (!self::numeric($value)) {$issues[]='not_numeric';$value=null;}
if (!is_int($updated) || $updated<=0) {$issues[]='timestamp';$updated=null;}
if (($raw['issues']??null)!==[]) $issues[]='capture_quality'; // never copy arbitrary source text
if ($updated!==null && ($updated>$now || $now-$updated>($s['maxAgeSeconds']??$p['extraMaxAgeSeconds']))) $issues[]='source_age';
$scaled=$value===null?null:(float)$value*($s['factorToW']??1.0);
if ($scaled!==null && (!is_finite($scaled)||abs($scaled)>1e12)) {$scaled=null;$issues[]='converted_value';}
$changed=$raw['sourceChangedAt']??null;
if (!is_int($changed)||$changed<0||$updated===null||$changed>$updated) $changed=null;
$v[$key]=['value'=>$scaled,'updated'=>$updated,'changed'=>$changed,'issues'=>$issues];
}
$closure=function(array $keys)use(&$closure,$sources):array {
$out=[];foreach($keys as $key){if(!isset($sources[$key]))throw new InvalidArgumentException('Missing origin');$out[]=$key;
foreach($closure($sources[$key]['dependsOn']??[]) as $dep)$out[]=$dep;}
return array_values(array_unique($out));
};
$quality=function(array $keys)use($closure,$v,$c,$duration):array {
$keys=$closure($keys);$issues=[];$ts=[];
foreach($keys as $key){foreach($v[$key]['issues'] as $i)$issues[]=$key.':'.$i;if($v[$key]['updated']!==null)$ts[]=$v[$key]['updated'];}
$skew=$ts===[]?null:max($ts)-min($ts);
if($duration>5)$issues[]='long_acquisition';
if($skew!==null && $skew>$c['accounting']['maxSkewSeconds'])$issues[]='source_time_skew';
return ['status'=>$issues===[]?'consistent_candidate':'quality_hold','issues'=>$issues,'oldestSourceUpdatedAt'=>$ts===[]?null:min($ts),'sourceSkewSeconds'=>$skew];
};
$byRole=[];foreach($c['accounting']['sources'] as $s)$byRole[$s['role']][]=$s['key'];
$physical=$byRole['physical_storage'];$pv=$byRole['pv'];$grid=$byRole['grid'];$flex=$byRole['flexible_load']??[];
$sum=function(array $keys)use($v):?float { $total=0.0;foreach($keys as $key){if($v[$key]['value']===null)return null;$total+=$v[$key]['value'];}return $total;};
$storage=$quality($physical);$storage['physicalStorageCandidateW']=$sum($physical);
$solar=['status'=>'not_configured','acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null,'issues'=>['origin_not_configured']];
$loadKeys=array_merge($grid,$pv,$physical,$flex);$sr=$p['solarReference'];
if($sr!==null){
$origin=[$sr['rawKey'],$sr['scaleKey'],$sr['batteryKey']];$loadKeys=array_merge($loadKeys,$origin);
$solar=$quality($origin);$raw=$v[$sr['rawKey']]['value'];$sf=$v[$sr['scaleKey']]['value'];
$solar+=['acTerminalCandidateW'=>null,'reconstructedPvCandidateW'=>null,'derivedPvDifferenceW'=>null];
// int16 invalid sentinel excluded, including scale sentinel; no unsigned guessing.
if($raw===null||$sf===null||floor($raw)!==$raw||$raw<=-32768||$raw>32767||floor($sf)!==$sf||$sf < -6||$sf>6){
$solar['status']='quality_hold';$solar['issues'][]='invalid_raw_or_scale';
}else{
$ac=$raw*(10**(int)$sf);
if(abs($ac)>1e9){$solar['status']='quality_hold';$solar['issues'][]='scaled_power_range';}
else{$solar['acTerminalCandidateW']=$ac;$b=$v[$sr['batteryKey']]['value'];
if($b!==null)$solar['reconstructedPvCandidateW']=$ac+$b;
$reported=$v[$sr['pvKey']]['value'];
if($reported!==null&&$solar['reconstructedPvCandidateW']!==null)$solar['derivedPvDifferenceW']=$reported-max(0.0,$solar['reconstructedPvCandidateW']);}
}
}
$load=$quality($loadKeys);$net=$sum($grid);$pvSum=$sum($pv);$phys=$sum($physical);$fl=$sum($flex);
$candidate=($net===null||$pvSum===null||$phys===null||$fl===null)?null:$net+$pvSum-$phys-$fl;
if($candidate!==null&&$candidate<0){$load['status']='quality_hold';$load['issues'][]='negative_load';}
foreach(array_merge($pv,$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$load['status']='quality_hold';$load['issues'][]='negative_unsigned:'.$key;}
if($sr!==null&&$solar['status']!=='consistent_candidate'){$load['status']='quality_hold';$load['issues'][]='solar_origin_unverified_value';}
$load+=['loadCandidateW'=>$candidate,'gridW'=>$net,'pvCandidateW'=>$pvSum,'physicalStorageCandidateW'=>$phys,'flexibleLoadW'=>$fl,
'virtualAllocationUsed'=>false,'measurementBoundaryVerified'=>false];
// AC terminal alternative eliminates SolarEdge battery/PV cancellation and zero-clamp artefact.
$alternative=null;$altQuality=null;
if($sr!==null){
$altKeys=array_values(array_diff(array_merge($grid,$pv,$physical,$flex),[$sr['pvKey'],$sr['batteryKey']]));
$altKeys=array_merge($altKeys,[$sr['rawKey'],$sr['scaleKey']]);$altQuality=$quality($altKeys);
$pvRest=$sum(array_values(array_diff($pv,[$sr['pvKey']])));$bRest=$sum(array_values(array_diff($physical,[$sr['batteryKey']])));
if($net!==null&&$pvRest!==null&&$bRest!==null&&$fl!==null&&$solar['acTerminalCandidateW']!==null){
$alternative=$net+$pvRest-$bRest+$solar['acTerminalCandidateW']-$fl;
if($alternative<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_load';}
}else{$altQuality['status']='quality_hold';$altQuality['issues'][]='solar_ac_unavailable';}
foreach(array_merge(array_values(array_diff($pv,[$sr['pvKey']])),$flex) as $key)if($v[$key]['value']!==null&&$v[$key]['value']<0){$altQuality['status']='quality_hold';$altQuality['issues'][]='negative_unsigned:'.$key;}
$altQuality+=['loadCandidateW'=>$alternative,'measurementBoundaryVerified'=>false,'solarBatterySubtractedAgain'=>false];
}
$requestKeys=[$p['evRequestKey'],$p['sdlRequestKey']];$alloc=$quality(array_merge($physical,$requestKeys));
$ev=$v[$p['evRequestKey']]['value'];$sdl=$v[$p['sdlRequestKey']]['value'];$evModel=null;$sdlModel=null;$unallocated=$phys;$delta=null;
foreach($requestKeys as $key){
if($v[$key]['changed']===null){$alloc['issues'][]='request_change_time_unknown:'.$key;$alloc['status']='quality_hold';}
elseif($storage['oldestSourceUpdatedAt']!==null && $v[$key]['changed']>$storage['oldestSourceUpdatedAt']){$alloc['issues'][]='request_newer_than_feedback:'.$key;$alloc['status']='quality_hold';}
}
if($phys!==null&&$ev!==null&&$sdl!==null){
$abs=abs($ev)+abs($sdl);$delta=$phys-$ev-$sdl;
if($abs>=1.0){$evModel=$ev+$delta*abs($ev)/$abs;$sdlModel=$phys-$evModel;$unallocated=0.0;
if(abs($delta)>$p['trackingToleranceW']){$alloc['issues'][]='physical_request_tracking_error';$alloc['status']='quality_hold';}
}else{$alloc['issues'][]='idle_power_unallocated';$alloc['status']='quality_hold';}
}
$reportedEv=$sum($byRole['controlled_virtual']);$reportedSdl=$sum($byRole['external_virtual']??[]);
$reportedResidual=($phys===null||$reportedEv===null||$reportedSdl===null)?null:$phys-$reportedEv-$reportedSdl;
$alloc+=['physicalStorageCandidateW'=>$phys,'requestedEvW'=>$ev,'requestedSdlW'=>$sdl,'unfilteredModelEvW'=>$evModel,
'unfilteredModelSdlW'=>$sdlModel,'unallocatedPhysicalW'=>$unallocated,'physicalRequestErrorW'=>$delta,
'reportedEvW'=>$reportedEv,'reportedSdlW'=>$reportedSdl,'reportedAllocationResidualW'=>$reportedResidual,
'reportedVirtualQuality'=>$quality(array_merge($byRole['controlled_virtual'],$byRole['external_virtual']??[])),
'underlyingTimesMeaning'=>'Symcon VariableUpdated; not independently measured EV/SDL channel times',
'reportedAllocationWithinTolerance'=>$reportedResidual!==null&&abs($reportedResidual)<=$c['accounting']['splitToleranceW'],
'separatelyMeasured'=>false,'method'=>'request_weighted_residual_unfiltered_not_unique_measurement'];
return ['schemaVersion'=>1,'kind'=>'separated_measurement_views','installationId'=>$c['installationId'],'capturedAt'=>$r['capturedAt'],
'mappingSha256'=>$mapping,'policySha256'=>hash('sha256',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)),
'physicalLoad'=>$load,'storageFeedback'=>$storage,'allocationModel'=>$alloc,'solarOrigin'=>$solar,'solarAcAlternative'=>$altQuality,
'trainingEligible'=>false,'canDispatch'=>false,'controlEnabled'=>false,'socAccountsChanged'=>false,'futureSdl'=>'unknown_no_assumption',
'note'=>'Observation only. Source times are Symcon update times. Algebraic partition is not separate EV/SDL measurement. GoodWe and combined AC/DC boundaries remain unverified.'];
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4MesssichtTest extends TestCase
{
/** @dataProvider scripts */
public function testIsolatedViews(string $script): void
{
$p=proc_open([PHP_BINARY,__DIR__.'/V4SeparatedObservation/'.$script],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
self::assertSame(0,proc_close($p),$out.$err);self::assertStringContainsString('TOTAL ',$out);self::assertSame('',$err);
}
public function scripts(): array {return [['checks.php'],['native_checks.php']];}
}
+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";