122 lines
8.3 KiB
PHP
122 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Belevo\EnelixEMS;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
/** Pure accounting candidate. No IPS, archive changes, network or control permission.
|
|
* Battery-positive = charging. Grid-positive = import. PV-positive = production.
|
|
* Physical storage is subtracted ONCE from house load. Virtual allocation is a
|
|
* separate ledger, not a second physical meter. Mapping accuracy requires review.
|
|
*/
|
|
final class NetzfahrplanV4Bilanzierung
|
|
{
|
|
private static function number($v, string $name, float $min = -1.0e9, float $max = 1.0e9): float
|
|
{
|
|
if ((!is_int($v) && !is_float($v)) || !is_finite((float) $v) || $v < $min || $v > $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.');
|
|
}
|
|
// Preserve a stable numeric representation across JSON property round-trips.
|
|
$c['splitToleranceW'] = 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.'];
|
|
}
|
|
}
|