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