$max) { throw new InvalidArgumentException($name . ': ungueltiger Zahlenwert.'); } return (float) $v; } private static function listArray(array $a): bool { return $a === [] || array_keys($a) === range(0, count($a)-1); } public static function konfigurieren(array $c): array { $fields = ['version','sources','maxSkewSeconds','splitToleranceW']; if (count($c) !== count($fields) || array_diff($fields,array_keys($c)) || $c['version'] !== 1 || !is_array($c['sources']) || !self::listArray($c['sources']) || count($c['sources']) < 3 || count($c['sources']) > 80 || !is_int($c['maxSkewSeconds']) || $c['maxSkewSeconds'] < 0 || $c['maxSkewSeconds'] > 60) { throw new InvalidArgumentException('Bilanzkonfiguration unvollstaendig.'); } self::number($c['splitToleranceW'], 'Zuordnungstoleranz W', 0, 500); $seen=[];$keys=[];$roles=[]; foreach ($c['sources'] as &$s) { if (!is_array($s) || count($s)!==8 || array_diff(['key','role','variableId','parentId','ident','factorToW','maxAgeSeconds','dependsOn'],array_keys($s))) { throw new InvalidArgumentException('Explizite Quellenzuordnung erforderlich.'); } if (!is_string($s['key']) || !preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,63}$/D',$s['key']) || isset($keys[$s['key']]) || !in_array($s['role'], ['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],true) || !is_int($s['variableId']) || $s['variableId']<=0 || isset($seen[$s['variableId']]) || !is_int($s['parentId']) || $s['parentId']<=0 || !is_string($s['ident']) || strlen($s['ident'])>100 || !is_int($s['maxAgeSeconds']) || $s['maxAgeSeconds']<1 || $s['maxAgeSeconds']>300) { throw new InvalidArgumentException('Doppelte oder ungueltige Bilanzquelle.'); } $s['factorToW']=self::number($s['factorToW'],'Einheiten-/Vorzeichenfaktor',-1e6,1e6); if ($s['factorToW']==0 || !is_array($s['dependsOn']) || !self::listArray($s['dependsOn']) || count($s['dependsOn'])>40) { throw new InvalidArgumentException('Abhaengigkeiten oder Faktor fehlen.'); } foreach ($s['dependsOn'] as $dep) if (!is_string($dep)) throw new InvalidArgumentException('Quellenschluessel erforderlich.'); if (count(array_unique($s['dependsOn']))!==count($s['dependsOn'])) throw new InvalidArgumentException('Doppelte Abhaengigkeit.'); sort($s['dependsOn']);$keys[$s['key']]=true;$seen[$s['variableId']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1; } unset($s); if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'],$roles['controlled_virtual'])) { throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.'); } $graph=[];foreach ($c['sources'] as $s) $graph[$s['key']]=$s['dependsOn']; $visit=function($key,$stack=[]) use (&$visit,$graph): void { if (!array_key_exists($key,$graph) || isset($stack[$key])) throw new InvalidArgumentException('Fehlende oder zyklische Ursprungsquelle.'); $stack[$key]=true;foreach ($graph[$key] as $dep) $visit($dep,$stack); }; foreach (array_keys($graph) as $key) $visit($key); usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));return $c; } /** Reader: {value:number, updated:int, parentID:int, ident:string}. */ public static function aufnehmen(array $configuration, callable $read, int $now): array { if ($now<=0) throw new InvalidArgumentException('Ungueltige Aufnahmezeit.'); $c=self::konfigurieren($configuration);$samples=[];$sources=[];$issues=[]; $probe=static function(array $s) use ($read): array { $v=$read($s['variableId']); if (!is_array($v) || ($v['parentID']??null)!==$s['parentId'] || ($v['ident']??null)!==$s['ident'] || !is_int($v['updated']??null) || $v['updated']<=0) throw new InvalidArgumentException('Quelle/Originalzeitpunkt ungueltig: '.$s['key']); return ['raw'=>self::number($v['value']??null,$s['key']), 'updated'=>$v['updated']]; }; foreach ($c['sources'] as $s) {$sources[$s['key']]=$s;$samples[$s['key']]=$probe($s);} foreach ($c['sources'] as $s) if ($samples[$s['key']]!==$probe($s)) throw new InvalidArgumentException('Aufnahme waehrend des Lesens geaendert: '.$s['key']); $origin=function($key) use (&$origin,$sources,$samples): int { $stamp=$samples[$key]['updated'];foreach ($sources[$key]['dependsOn'] as $dep) $stamp=min($stamp,$origin($dep));return $stamp; }; $totals=array_fill_keys(['grid','pv','physical_storage','controlled_virtual','external_virtual','flexible_load','reference'],0.0); $out=[];$times=[]; foreach ($sources as $key=>$s) { $t=$origin($key);$w=$samples[$key]['raw']*$s['factorToW']; if (!is_finite($w) || abs($w)>1e9) throw new InvalidArgumentException('Leistung ausserhalb des Bereichs.'); if ($samples[$key]['updated']>$now || $t>$now) $issues[]='future_source:'.$key; if ($now-$t>$s['maxAgeSeconds']) $issues[]='stale_origin:'.$key; if (in_array($s['role'],['pv','flexible_load'],true) && $w<0) $issues[]='negative_unsigned_source:'.$key; $totals[$s['role']]+=$w; if ($s['role']!=='reference') $times[]=$t; $out[$key]=['variableId'=>$s['variableId'],'role'=>$s['role'],'powerW'=>$w,'sourceUpdatedAt'=>$samples[$key]['updated'],'oldestOriginAt'=>$t]; } $skew=max($times)-min($times);if ($skew>$c['maxSkewSeconds']) $issues[]='source_time_skew'; $load=$totals['grid']+$totals['pv']-$totals['physical_storage']; $base=$load-$totals['flexible_load']; // This channel preserves unallocated dynamics instead of hiding them in house load. $residual=$totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual']; $external=$totals['external_virtual']+$residual; if ($base<0) $issues[]='negative_base_load'; if (abs($residual)>$c['splitToleranceW']) $issues[]='virtual_physical_allocation_mismatch'; $reconstructed=$base+$totals['flexible_load']+$totals['controlled_virtual']+$external-$totals['pv']; $identity=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)); return ['version'=>1,'status'=>$issues===[]?'consistent_candidate':'quality_hold', 'capturedAt'=>gmdate('c',$now),'sourceObservedFrom'=>gmdate('c',min($times)),'sourceObservedUntil'=>gmdate('c',max($times)), 'sourceSkewSeconds'=>$skew,'mappingId'=>'accounting-map:'.$identity, 'baseLoadCandidateW'=>$base,'consumerLoadCandidateW'=>$load,'pvW'=>$totals['pv'],'gridW'=>$totals['grid'], 'physicalStorageW'=>$totals['physical_storage'],'controlledVirtualW'=>$totals['controlled_virtual'], 'flexibleLoadW'=>$totals['flexible_load'],'reportedExternalW'=>$totals['external_virtual'], 'allocationResidualW'=>$residual,'externalEffectW'=>$external,'reconstructedGridW'=>$reconstructed, 'arithmeticBalanceErrorW'=>$reconstructed-$totals['grid'],'issues'=>$issues,'sources'=>$out, 'controlEligible'=>false,'meteringBoundaryVerified'=>false,'forecastHistoryMigrated'=>false, 'note'=>'Algebraic closure is not independent meter validation. No relabelling of old forecasts, no future SDL assumption.']; } }