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