feat(accounting): separate physical storage and SDL with source-age validation
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?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.');
|
||||||
|
}
|
||||||
|
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.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace Belevo\EnelixEMS\Tests;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
final class NetzfahrplanV4BilanzierungTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIsolatedAccountingChecks(): void
|
||||||
|
{
|
||||||
|
$pipes=[];
|
||||||
|
$p=proc_open([PHP_BINARY,__DIR__.'/V4Accounting/checks.php'],[0=>['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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php';
|
||||||
|
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as B;
|
||||||
|
$count=0;
|
||||||
|
function check(bool $yes,string $name): void {global $count;if(!$yes)throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
|
||||||
|
function fails(callable $f,string $name):void {try{$f();}catch(InvalidArgumentException $e){check(true,$name);return;}throw new RuntimeException('FAIL expected refusal: '.$name);}
|
||||||
|
function fixture():array {
|
||||||
|
$now=1790950000;$s=[];$m=[];
|
||||||
|
foreach(['grid'=>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";
|
||||||
Reference in New Issue
Block a user