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
+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.'];
}
}