From e0e52d2454c24d462d84414a8cd4db47b26eab1d Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Fri, 2 Oct 2026 14:23:19 +0000 Subject: [PATCH] feat(accounting): separate physical storage and SDL with source-age validation --- docs/netplan-v4-accounting.md | 99 +++++++++++++++++++ libs/NetzfahrplanV4Bilanzierung.php | 120 +++++++++++++++++++++++ tests/NetzfahrplanV4BilanzierungTest.php | 16 +++ tests/V4Accounting/checks.php | 56 +++++++++++ 4 files changed, 291 insertions(+) create mode 100644 docs/netplan-v4-accounting.md create mode 100644 libs/NetzfahrplanV4Bilanzierung.php create mode 100644 tests/NetzfahrplanV4BilanzierungTest.php create mode 100644 tests/V4Accounting/checks.php diff --git a/docs/netplan-v4-accounting.md b/docs/netplan-v4-accounting.md new file mode 100644 index 0000000..f921543 --- /dev/null +++ b/docs/netplan-v4-accounting.md @@ -0,0 +1,99 @@ +# V4: getrennte Last-/Batterie-/SDL-Bilanz (Kandidat, keine Stellfreigabe) + +## Status 2026-10-02 + +`NetzfahrplanV4Bilanzierung` ist ein reiner Rechen-/Validierungsbaustein. Noch keine +Einbindung in die laufende Telemetrie, den Trainer oder den Kontrolltest. Er ruft +keine IPS-, Netzwerk-, Archiv- oder Stellfunktionen auf. Ergebnis ist immer +`controlEligible=false`, auch bei geschlossener mathematischer Bilanz. + +34 isolierte PHP-Pruefungen wurden mit der gleichen SHA256-Datei auf iot-symcon01 +bestanden. Das ist kein Nachweis fuer die reale Hardware-/Messgrenzenzuordnung +und kein vollstaendiger PHPUnit- oder Symcon-Kernel-Test. + +## Gefundener Unterschied in Lihrenmoos + +Installierter Manager `Manager/module.php::leseMessleistungen()` verwendet: + + Haus = PV + Netz - Summe der konfigurierten Batterie-Istleistungen + +Konfiguriert ist hier die virtuelle EV-Leistung 52020, nicht die gesamte physische +Batterieleistung. Daher bleibt der externe SDL-Anteil und eine allfaellige +Abweichung der virtuellen Aufteilung im an den Prognosedienst gesendeten Hauswert. +`sendePrognoseTelemetrie()` sendet genau diesen errechneten Wert. Die parallel +existierenden Hausvariablen 46714 und 40713 sind NICHT automatisch diese Quelle. + +Der bisherige Gateway `Bat_EV_SDL_V4` liest die physische Batterieleistung aus +47725, 35724 und 36380 und invertiert diese Vorzeichen. EV und SDL sind daraus +abgeleitete, separat gefilterte virtuelle Konten, keine zwei unabhaengigen Zaehler. +Die Filter koennen alte Teilwerte halten. `UpdateActualPowerSplit` aktualisiert die +Ausgabevariablen trotzdem. Ein neuer Ausgabezeitstempel beweist deshalb nicht die +Frische aller darunter liegenden Originalmessungen. + +## Kandidat fuer die korrekte Bilanz + +Mit einheitlicher Messgrenze und Vorzeichen Batterie+ = Laden, Netz+ = Bezug: + + Verbraucherlast = Netz + PV - gesamte physische Batterieleistung + Grundlast = Verbraucherlast - separat geplante flexible Verbraucher + Zuordnungsrest = physische Batterie - virtuell steuerbare Batterie - externe virtuelle Konten + Externer Netzeffekt = externe virtuelle Konten + Zuordnungsrest + Netz = Grundlast + flexible Last + steuerbare Batterie + externer Netzeffekt - PV + +Der Zuordnungsrest wird nicht erneut als Hausverbrauch trainiert, sondern separat +angezeigt. Eine grosse Abweichung sperrt die Datenqualitaet. Gegenlaeufige virtuelle +Konten (z.B. EV +42 kW, SDL -40 kW, physisch +2 kW) bleiben korrekt getrennt. +Spaetere Ladestationen/Boiler sind ueber `flexible_load` erweiterbar. Solange sie +nicht separat geplant werden, verbleiben sie in der nicht separat steuerbaren Last. + +Keine Messgroesse wird doppelt subtrahiert. Ein negatives Grundlastergebnis wird +nicht heimlich auf null begrenzt. Numerische Nullen sind echte Werte; fehlende, +nicht endliche oder umtypisierte Werte sind keine Nullen. + +## Originalzeit und Datenqualitaet + +Jede Quelle hat explizite Variable/Parent/Ident, Faktor, maximales Alter und optionale +Ursprungsabhaengigkeiten. Alle Quellen werden zweimal gelesen. Wertewechsel waehrend +der Aufnahme werden abgewiesen; Zeitstempel werden nie kuenstlich verjuengt. +Abhaengigkeiten sind zyklenfrei, doppelte Quellen werden verhindert. +Eine virtuelle Summenvariable erbt das aelteste Datum ihrer physischen Eingangsquellen. +Konfigurierbare Alters-/Synchronitaetspruefung ist erforderlich. Diagnoseberechnungen +mit ungeeigneten Daten bleiben `quality_hold`, nicht `base_load`-Trainingsfreigabe. +Die momentanen Beispielgrenzen (60 s Alter, 30 s Versatz) sind konservative +Diagnoseparameter, keine garantierten Geraeteeigenschaften. + +Die konkrete Solar-PV-Herkunft sowie AC/DC-/Wirkungsgradgrenzen aller Hybridgeraete +sind noch zu verifizieren. Eine algebraisch geschlossene Gleichung beweist diese +physische Messgrenze NICHT. Die vorgeschlagene Quellkonfiguration ist nicht freigegeben. + +## Migration in die Prognose (noch offen) + +1. Originalmessquellen und Messgrenze dokumentieren und Qualitaet ueberwachen. +2. Korrigierte Grundlast und externe Kanaele als NEUE versionierte Zeitreihen erfassen. +3. Bestehende Historien nicht ueberschreiben oder als bereinigte Historie umbenennen. + Rekonstruktion nur bei ausreichenden zeitlich passenden Originaldaten. +4. Prognosemodell auf der korrigierten Basis trainieren/validieren. +5. Zukunfts-SDL separat liefern. Unbekannt ist nicht null; eine geschaetzte Annahme + muss explizit bezeichnet werden und darf nicht als veroeffentlichter SDL-Fahrplan gelten. +6. Erst nach diesen Pruefungen eine echte Bilanzierungsreferenz im Trial-Gate verwenden. + `mappingId` aus dieser Klasse ist ein Konfigurationsfingerabdruck, KEIN solcher Nachweis. + +## Gateway-Ausfallverhalten (nicht getestet/freigegeben) + +Quelltextpruefung des installierten Alt-Gateways ergab keinen Frischetest fuer +`Nennleistung_Soll_EV` im Weg `Update -> ApplySetpoints`. Der zwischengespeicherte +EV-Sollwert wird weiter verwendet, solange die Gateway-Schleife laeuft. Der Eingang +wird von `scripts/31800.ips.php` geschrieben; dieser enthaelt ebenfalls keine Lease. +Der vorhandene 30-s-Timeout des EMS-Batterieadapters ist ein Schutz innerhalb von +Symcon, nicht ein unabhaengiger Wechselrichter-Timeout bei Rechnerausfall. + +`State=false` im Alt-Gateway beendet `Update` ohne dort Nullwerte zu schreiben. +Diesen Schalter nicht als nachgewiesenen Hardware-Notstopp verwenden. +Keine Kommunikationsunterbrechung, kein Abschalttest und keine neue Stellwertausgabe +wurden an der realen Anlage durchgefuehrt. Auch aus dem fehlenden Code kann NICHT +gefolgert werden, dass das Geraet selbst keinen Timeout hat. Exakte Typen/Firmware +und das wirksame geraeteseitige Verhalten muessen noch festgestellt werden. + +Wichtig fuer spaetere Anpassung: Ein EV-Befehlsablauf darf nicht pauschal den +externen SDL-Auftrag loeschen. Ein Kernel-interner Timer ersetzt wiederum keinen +Schutz bei komplettem Kernel-/Rechner-/Kommunikationsausfall. diff --git a/libs/NetzfahrplanV4Bilanzierung.php b/libs/NetzfahrplanV4Bilanzierung.php new file mode 100644 index 0000000..38f61a2 --- /dev/null +++ b/libs/NetzfahrplanV4Bilanzierung.php @@ -0,0 +1,120 @@ + $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.']; + } +} diff --git a/tests/NetzfahrplanV4BilanzierungTest.php b/tests/NetzfahrplanV4BilanzierungTest.php new file mode 100644 index 0000000..6e1145a --- /dev/null +++ b/tests/NetzfahrplanV4BilanzierungTest.php @@ -0,0 +1,16 @@ +['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes); + self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]); + fclose($pipes[1]);fclose($pipes[2]);self::assertSame(0,proc_close($p),$out.$err);self::assertSame('',$err); + self::assertStringContainsString('TOTAL 34 checks passed',$out); + } +} diff --git a/tests/V4Accounting/checks.php b/tests/V4Accounting/checks.php new file mode 100644 index 0000000..2d9c806 --- /dev/null +++ b/tests/V4Accounting/checks.php @@ -0,0 +1,56 @@ +1000.,'pv'=>5000.,'physical_storage'=>3000.,'controlled_virtual'=>2000.,'external_virtual'=>1000.] as $role=>$value){ + $id=101+count($s);$s[]=['key'=>$role,'role'=>$role,'variableId'=>$id,'parentId'=>10,'ident'=>$role,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]]; + $m[$id]=['value'=>$value,'updated'=>$now,'parentID'=>10,'ident'=>$role]; + } + $s[3]['dependsOn']=['physical_storage'];$s[4]['dependsOn']=['physical_storage']; + return [['version'=>1,'sources'=>$s,'maxSkewSeconds'=>30,'splitToleranceW'=>100.],$m,$now]; +} +function take(array $c,array $m,int $now):array{return B::aufnehmen($c,static fn($id)=>$m[$id],$now);} +[$c,$m,$n]=fixture();$r=take($c,$m,$n); +check($r['baseLoadCandidateW']===3000.,'subtract physical battery once, not virtual twice'); +check($r['externalEffectW']===1000. && $r['allocationResidualW']===0.,'SDL kept outside household'); +check($r['reconstructedGridW']===1000. && $r['arithmeticBalanceErrorW']===0.,'signed PCC balance closes'); +check($r['controlEligible']===false && !$r['meteringBoundaryVerified'],'algebra never grants control'); +check(!$r['forecastHistoryMigrated'],'old forecasts never relabelled'); +$m[101]['value']=-5000.;$m[103]['value']=-3000.;$m[104]['value']=-2000.;$m[105]['value']=-1000.;$r=take($c,$m,$n); +check($r['baseLoadCandidateW']===3000. && $r['externalEffectW']===-1000.,'discharge and export signs correct'); +[$c,$m,$n]=fixture();$m[103]['value']=2000.;$m[104]['value']=42000.;$m[105]['value']=-40000.;$r=take($c,$m,$n); +check($r['allocationResidualW']===0. && $r['baseLoadCandidateW']===4000.,'opposed virtual accounts are not two physical loads'); +[$c,$m,$n]=fixture();$m[104]['value']=0.;$m[105]['value']=0.;$r=take($c,$m,$n); +check($r['externalEffectW']===3000. && $r['baseLoadCandidateW']===3000.,'held virtual values cannot contaminate base load'); +check(in_array('virtual_physical_allocation_mismatch',$r['issues'],true),'split discrepancy explicit'); +[$c,$m,$n]=fixture();$m[103]['updated']=$n-120;$r=take($c,$m,$n); +check(in_array('stale_origin:physical_storage',$r['issues'],true),'old physical reading rejected despite new acquisition'); +check(in_array('stale_origin:controlled_virtual',$r['issues'],true),'fresh virtual timestamp cannot mask old physical input'); +check($r['sources']['controlled_virtual']['oldestOriginAt']===$n-120,'original dependency timestamp preserved'); +check($r['status']==='quality_hold','bad quality remains a diagnostic, not a training sample'); +[$c,$m,$n]=fixture();$m[102]['updated']=$n-31;$r=take($c,$m,$n);check(in_array('source_time_skew',$r['issues'],true),'skew rejected separately from age'); +$m[102]['updated']=$n+1;$r=take($c,$m,$n);check(in_array('future_source:pv',$r['issues'],true),'future timestamp rejected'); +[$c,$m,$n]=fixture();foreach($m as &$v)$v['value']=0.;unset($v);$r=take($c,$m,$n);check($r['status']==='consistent_candidate' && $r['baseLoadCandidateW']===0.,'true all-zero observation accepted without fake history'); +[$c,$m,$n]=fixture();$m[101]['value']=-6000.;$r=take($c,$m,$n);check($r['baseLoadCandidateW']===-4000. && in_array('negative_base_load',$r['issues'],true),'negative residual not silently clipped to zero'); +[$c,$m,$n]=fixture();$m[102]['value']=-10.;check(in_array('negative_unsigned_source:pv',take($c,$m,$n)['issues'],true),'PV sign validity'); +[$c,$m,$n]=fixture();$m[103]['value']=-3000.;$c['sources'][2]['factorToW']=-1.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'explicit vendor sign conversion'); +[$c,$m,$n]=fixture();$m[101]['value']=1.;$c['sources'][0]['factorToW']=1000.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'kW to W factor applied once'); +[$c,$m,$n]=fixture();$c['sources'][]=['key'=>'boiler','role'=>'flexible_load','variableId'=>200,'parentId'=>10,'ident'=>'boiler','factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];$m[200]=['value'=>500.,'updated'=>$n,'parentID'=>10,'ident'=>'boiler'];$r=take($c,$m,$n);check($r['baseLoadCandidateW']===2500. && $r['reconstructedGridW']===1000.,'future flexible load excluded exactly once'); +foreach([true,'1000',NAN,INF] as $bad){[$c,$m,$n]=fixture();$m[101]['value']=$bad;fails(fn()=>take($c,$m,$n),'invalid numeric input '.gettype($bad));} +[$c,$m,$n]=fixture();$m[101]['parentID']=99;fails(fn()=>take($c,$m,$n),'different source parent rejected'); +[$c,$m,$n]=fixture();$m[101]['ident']='other';fails(fn()=>take($c,$m,$n),'different source ident rejected'); +[$c,$m,$n]=fixture();$c['sources'][]=$c['sources'][0];fails(fn()=>take($c,$m,$n),'duplicate physical source rejected'); +[$c,$m,$n]=fixture();$c['sources'][3]['dependsOn']=['absent'];fails(fn()=>take($c,$m,$n),'missing origin rejected'); +[$c,$m,$n]=fixture();$c['sources'][2]['dependsOn']=['controlled_virtual'];fails(fn()=>take($c,$m,$n),'dependency loop rejected'); +[$c,$m,$n]=fixture();$a=take($c,$m,$n)['mappingId'];$c['sources']=array_reverse($c['sources']);check(take($c,$m,$n)['mappingId']===$a,'source order does not change mapping identity'); +$c['sources'][0]['factorToW']=2.;check(take($c,$m,$n)['mappingId']!==$a,'factor changes invalidate old mapping identity'); +[$c,$m,$n]=fixture();$calls=[]; +fails(function()use($c,$m,$n,&$calls){B::aufnehmen($c,function($id)use($m,&$calls){$calls[$id]=($calls[$id]??0)+1;$v=$m[$id];if($calls[$id]>1)$v['value']++;return $v;},$n);},'same-second concurrent value change rejected'); +$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php');check(!preg_match('/\b(?:SetValue|IPS_RequestAction|RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$source),'adapter has no runtime or external write calls'); +echo "TOTAL $count checks passed; synthetic snapshots only.\n";