feat(observation): separate physical load and unfiltered EV-SDL model without dispatch
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
@@ -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']];}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user