From 9878197aeae59c48fd69a65a49742c6dbf6ab090 Mon Sep 17 00:00:00 2001 From: ENELIX Agent Date: Sat, 3 Oct 2026 11:40:54 +0000 Subject: [PATCH] feat(control): connect physical feedback to bounded V4 command and fallback paths --- Batterie/module.php | 20 ++- Manager/module.php | 6 + docs/netplan-v4-corrected-feedback.md | 108 ++++++++++++++ examples/V4CorrectedFeedback/MANIFEST.json | 65 ++++++++ examples/V4CorrectedFeedback/build_stage.py | 25 ++++ .../V4CorrectedFeedback/feedback-config.json | 71 +++++++++ .../V4CorrectedFeedback/file_installer.php | 66 ++++++++ examples/V4CorrectedFeedback/install.php | 68 +++++++++ ...atterieNetzfahrplanV4RueckmeldungTrait.php | 38 +++++ libs/BatterieNetzfahrplanV4TestTrait.php | 13 ++ libs/ManagerNetzfahrplanV4EmpfangTrait.php | 28 ++++ libs/ManagerNetzfahrplanV4TestTrait.php | 3 + libs/NetzfahrplanV4Regeltest.php | 26 ++++ libs/NetzfahrplanV4Rueckmeldung.php | 141 ++++++++++++++++++ tests/NetzfahrplanV4BilanzierungTest.php | 2 +- tests/NetzfahrplanV4DatenTest.php | 2 +- tests/NetzfahrplanV4RegeltestTest.php | 2 +- tests/NetzfahrplanV4RueckmeldungTest.php | 25 ++++ tests/V4ControlTrial/checks.php | 20 ++- tests/V4ControlTrial/feedback_checks.php | 22 +++ tests/V4Feedback/checks.php | 58 +++++++ tests/V4Feedback/fallback_checks.php | 23 +++ tests/V4Feedback/message_checks.php | 23 +++ tests/V4Feedback/receiver_checks.php | 43 ++++++ 24 files changed, 891 insertions(+), 7 deletions(-) create mode 100644 docs/netplan-v4-corrected-feedback.md create mode 100644 examples/V4CorrectedFeedback/MANIFEST.json create mode 100644 examples/V4CorrectedFeedback/build_stage.py create mode 100644 examples/V4CorrectedFeedback/feedback-config.json create mode 100644 examples/V4CorrectedFeedback/file_installer.php create mode 100644 examples/V4CorrectedFeedback/install.php create mode 100644 libs/BatterieNetzfahrplanV4RueckmeldungTrait.php create mode 100644 libs/NetzfahrplanV4Rueckmeldung.php create mode 100644 tests/NetzfahrplanV4RueckmeldungTest.php create mode 100644 tests/V4ControlTrial/feedback_checks.php create mode 100644 tests/V4Feedback/checks.php create mode 100644 tests/V4Feedback/fallback_checks.php create mode 100644 tests/V4Feedback/message_checks.php create mode 100644 tests/V4Feedback/receiver_checks.php diff --git a/Batterie/module.php b/Batterie/module.php index 8510535..d5e35e1 100644 --- a/Batterie/module.php +++ b/Batterie/module.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../libs/BatterieRegler.php'; require_once __DIR__ . '/../libs/BatterieNetzfahrplanV4TestTrait.php'; use Belevo\EnelixEMS\BatterieRegler; +use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung; use Belevo\EnelixEMS\Nachrichtenvertrag; use Belevo\EnelixEMS\VerbraucherBasisTrait; use Belevo\EnelixEMS\VerbraucherSchnittstelle; @@ -197,6 +198,10 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle !$this->ReadPropertyBoolean('NachladenMitMaximalleistung') ); + $formular['elements'][]=['type'=>'ExpansionPanel','caption'=>'V4 physische Rueckmeldung (keine Freigabe)', 'items'=>[ + ['type'=>'Label','caption'=>'Quellenzuordnung fuer Vorschau und separaten Regeltest. Eine Modellaufteilung ist keine separate EV-/SDL-Messung.'], + ['type'=>'ValidationTextBox','name'=>'NetzfahrplanV4RueckmeldungKonfiguration','caption'=>'Versionierte Quellenzuordnung (JSON)'] + ]]; return json_encode($formular, JSON_THROW_ON_ERROR); } @@ -392,6 +397,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $this->aktualisiereEnergie($jetzt); $messwerte = $this->leseMesswerte(); $messwerteGueltig = $messwerte['Gueltig']; + $this->SetBuffer('V4FeedbackEstimated', ($messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0'); $this->setzeZustand('Ladezustand', $messwerte['Ladezustand']); $this->setzeZustand('LadezustandGueltig', $messwerteGueltig); @@ -402,7 +408,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $this->setzeSichtbareVariable( 'Leistungsquelle', $messwerteGueltig - ? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + ? (($messwerte['V4FeedbackEstimated'] ?? false) ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN ); $this->aktualisiereLadestatus( @@ -527,6 +533,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $gueltig = true; foreach ($zuordnung as $property => $schluessel) { + if ($this->v4BatterieTestAktiv() && in_array($schluessel,['Netzleistung','Istleistung'],true)) continue; $variablenID = $this->ReadPropertyInteger($property); if (!$this->istMesswertAktuell($variablenID)) { $gueltig = false; @@ -535,6 +542,15 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle $werte[$schluessel] = (float) GetValue($variablenID); } + if ($this->v4BatterieTestAktiv()) { + try { + $f=$this->v4BatterieRueckmeldung(); + NetzfahrplanV4Rueckmeldung::requireTrial($f,$this->v4BatterieTestSitzung(),time()); + $werte['Istleistung']=$f['batteryW'];$werte['Netzleistung']=$f['gridW']; + $werte['V4FeedbackEstimated']=$f['estimated']; + } catch (Throwable $e) { $gueltig=false; } + } + if ($werte['MaxLaden'] <= 0.0 || $werte['MaxEntladen'] <= 0.0 || $werte['Ladezustand'] < 0.0 @@ -714,7 +730,7 @@ class Batterie extends IPSModule implements VerbraucherSchnittstelle ? (float) $this->leseZustand('Istleistung') : null, 'Leistungsquelle' => $messwerteGueltig - ? Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN + ? ($this->GetBuffer('V4FeedbackEstimated') === '1' ? Nachrichtenvertrag::LEISTUNGSQUELLE_BERECHNET : Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) : Nachrichtenvertrag::LEISTUNGSQUELLE_NICHT_VORHANDEN, 'Zustand' => [ [ diff --git a/Manager/module.php b/Manager/module.php index 82ab561..c12c6c6 100644 --- a/Manager/module.php +++ b/Manager/module.php @@ -724,7 +724,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle $lock = 'ENELIX.V4.Control.' . $this->InstanceID; if (!IPS_SemaphoreEnter($lock, 0)) return; try { + $trialBefore=(bool)$this->v4ManagerTestSession(); $this->regelnIntern($erneuern); + if ($trialBefore && !$this->v4ManagerTestSession() && $this->GetValue('Aktiv') + && IPS_GetInstance($this->InstanceID)['InstanceStatus'] === self::STATUS_AKTIV) { + $this->WriteAttributeString('LetzteSollwerte','{}'); + $this->regelnIntern(true); // exactly one fresh fallback, no cached pre-trial target + } if ($this->v4ManagerTestSession() && (!$this->GetValue('Aktiv') || IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== self::STATUS_AKTIV)) { $this->v4ManagerTestAbbrechen('Manager nicht mehr betriebsbereit'); diff --git a/docs/netplan-v4-corrected-feedback.md b/docs/netplan-v4-corrected-feedback.md new file mode 100644 index 0000000..527d837 --- /dev/null +++ b/docs/netplan-v4-corrected-feedback.md @@ -0,0 +1,108 @@ +# V4 corrected physical feedback -> local bounded control + +## Scope of this delivery + +The shared physical feedback reader is now connected to the actual Manager preview, +the existing explicit bounded-trial command path, and an independent reread in the +Battery module immediately before hardware output. This is not a new observer. +The installer leaves both local trial permissions disabled and cannot arm a server +trial. Normal operation continues through the existing local controller. + +Lihrenmoos binding: Manager 17004, Battery 44234, virtual asset +`anlage01-virtual-ev`. Effective 161.44 kWh / 39 kW unchanged. Source definitions +come from the existing versioned capture config and current saved topology, not +from new guessed device models. No old histories, sender cursors, virtual energy +accounts, SDL requests, archive policies or inverter polling are changed. + +## Feedback and meaning + +`NetzfahrplanV4Rueckmeldung` checks original value timestamps and object identities +in two read passes. Live max age is bounded to 60s and inter-source skew to 30s. +History interpolation/publication estimates are not accepted as live feedback. +The mapping fingerprint normalizes numbers and ordering, so a JSON property round +trip does not create a new identity. A mapping change cancels an active session. + +Two adapters exist: sum of explicitly assigned physical meters, and a virtual +EV/SDL partition model. For the latter, gateway state and current requests are +read but unchanged zero commands are not mistaken for failed sensor heartbeats. +When SDL request is exactly zero, current physical battery power is used rather +than the filtered virtual EV display. A new EV request does not invent an immediate +physical response or force the measured sign toward the desired sign. + +When SDL is nonzero, source timestamps must cover the latest request change and +physical tracking error must remain within the configured small tolerance. The +partition is explicitly estimated, not separately measured. Opposing EV/SDL flows +cannot be uniquely identified; they remain ineligible for trial control. Unknown +or stale physical sources never fall back to held filtered EV values. + +`allowEstimatedForTrial` is false in the prepared Lihrenmoos config. Configuring +feedback or seeing a valid preview therefore does NOT accept the estimate for a +trial and does NOT satisfy accounting/device-watchdog evidence or other gates. +This adapter does not claim to resolve arbitrary simultaneous SDL activity. + +## Actual control integration + +The same coherent grid/battery snapshot is used in the Manager. Battery command +is a TOTAL power, not a delta added to the existing command. Planned changes in +other consumers are counted once and remain explicitly separate from measured grid. + +A separate explicit server authority, both local consents, accounting evidence, +device-watchdog evidence, a deliberate session start and fresh plan remain required. +The existing trial is limited to 1800s / 5000W per direction; individual command +leases remain <=10s and cannot be renewed by replay or ordinary manager messages. +The local feedback mapping fingerprint and grid caps are bound to the session. + +Immediately before writing, Battery rereads physical feedback and applies current +SOC, reserve, hysteresis, availability and change-lock constraints. Its resulting +command must still meet the bound grid limits; an unreachable target revokes the +trial rather than claiming that the planned grid value was achieved. It cannot +claim physical performance merely because a register call returned successfully. + +Abort revokes the lease and attempts zero, and the Manager wrapper then runs at +most one fresh ordinary allocation, clearing cached pre-trial targets. It does +not recurse forever or revive an old plan. A failed stop stays explicitly failed. +The watchdog still requires a functioning kernel. Independent hardware failure +behavior is NOT certified by software tests. + +Outside a V4 session the existing battery power path is unchanged. The compatible +BatterieRegler library includes the already developed optional reserve charging +limit; with its default (maximal charge) 13,824 old/new ordinary-offer test cases +match exactly. That comparison is not an on-device dynamics test. + +## Installation + +A single prepared Symcon script is the next runtime action: + + require '/srv/agent/netplan-v4-feedback-stage/install.php'; + +It hash-checks nine changed files and all version-matched dependencies, backs up +existing source, installs dependencies before modules and reloads the ENELIX +library. Reload/ApplyChanges may execute existing initialization routines; this +is not promised to be a zero-effect library reload. It configures only the new +feedback mapping on Battery. No server redeployment is required. No shared classes +are included from staging, avoiding the earlier duplicate-class problem. + +A delayed module-registration result requests one repeat. A valid installation +can still report an unavailable feedback sample; that is not reinterpreted as zero. +Any preexisting trial permission, unsaved change, or concurrent source change stops +the installer. Source write failure rolls back its own changed files; reload errors +are reported for review and do not automatically authorize anything. + +## Validation and remaining acceptance + +125 isolated PHP functional checks cover the reader, real receiver, actual trial +traits with a simulated register driver, the extracted actual Manager fallback +wrapper, and the real Battery message builder with optional diagnostic variables absent. +Source quality is stored internally; a last partition estimate is not relabelled as a +measured value just because a trial ends or a diagnostic variable is hidden. Four full module linkage checks and ten isolated installer scenarios also +pass. Full PHP syntax and dependency hashes are checked. Neither these fixtures nor +the ordinary-offer comparison run the real Symcon kernel or an inverter. + +Evidence: test host `/srv/agent/netplan-v4-feedback-stage/PREPARATION.json`, +`TEST_RESULTS.txt`, `INSTALLER_TEST_RESULTS.json`, `ORDINARY_OFFER_TEST.json`. + +Runtime installation, real feedback reception, and a separately authorized field +trial remain outstanding. This finishes the code connection for bounded control; +it is NOT an unrestricted continuous-production controller or a hardware +commissioning certificate. Corrected model selection and actual real-world savings +must be checked against their own data. Existing safety gates are not bypassed. diff --git a/examples/V4CorrectedFeedback/MANIFEST.json b/examples/V4CorrectedFeedback/MANIFEST.json new file mode 100644 index 0000000..a9e7c3b --- /dev/null +++ b/examples/V4CorrectedFeedback/MANIFEST.json @@ -0,0 +1,65 @@ +{ + "scope": "corrected_feedback_trial_disabled", + "managerId": 17004, + "batteryInstanceId": 44234, + "files": { + "libs/BatterieNetzfahrplanV4RueckmeldungTrait.php": { + "before": null, + "after": "24cf0654f1a6bd545d64ce5f115f309b16d8ffca5172ec64fb37c390c6f001a7" + }, + "libs/BatterieNetzfahrplanV4TestTrait.php": { + "before": null, + "after": "80474ff24cf9b2d32dfe481f0ce26bb94584c458b0fa3de232dec3897b64a4b2" + }, + "libs/BatterieRegler.php": { + "before": "035988c54d4d0cc3a59d234f1c08a8edbf5fcac42e09a6bcea61d3dd8270771b", + "after": "beecc9c817b722336367e1c574c1a38afc8f3f5346442170d67c101b980adf7b" + }, + "libs/ManagerNetzfahrplanV4EmpfangTrait.php": { + "before": "d083cfa6ae8e8026c134d2b53f34bb6fbe342fb7332a3e7da1b881de33e13e97", + "after": "936939a3e4a5a5d54fdff68f6fc689a9400e8406aecd5ac09ebc8a6b569c0b66" + }, + "libs/ManagerNetzfahrplanV4TestTrait.php": { + "before": null, + "after": "c0d9b6336574dd8acf5f84adb12a32cad3b42ae91c5a31d513d0bc638a560f18" + }, + "libs/NetzfahrplanV4Regeltest.php": { + "before": null, + "after": "bdac3e2e54c50a4c1541da8918ddf3ab2f665bb5cc6696848f546e7b05f5f249" + }, + "libs/NetzfahrplanV4Rueckmeldung.php": { + "before": null, + "after": "a7459a208c4dd84ffe3a166d266dcd00216f3660fe60e5ee61d5042d0b840631" + }, + "Batterie/module.php": { + "before": "34503645f7e21dd1e5bf0a3f754f6592175dd67f9a263bf431f3801209555401", + "after": "3d5fcd83e4e7d999d5ac949fe357b492d5661ffa76639d2ca57a3f3934eabdbe" + }, + "Manager/module.php": { + "before": "83e80883c8f9b817c0af5e239b20bcb019c7cf7f7943ee5bcc6493ac5a743532", + "after": "cbbb33a8f71dda8cc80a4fc01cd3d6154da8153b5523306b2da723bb44f2dd79" + } + }, + "configurationSha256": "f66cfb9ebf5e02259b902f876a4c78756a51b3f2f2c240fd92c0f01161268c56", + "dependencies": { + "libs/NetzfahrplanV4Planpruefung.php": "24b935d80bad4ce2d9047b6777aaf74a18773236ca91a2844a0907012f4fadf4", + "libs/Nachrichtenvertrag.php": "215ad7d4c5240b39b48cb7672f5057afa532aafdb34039862a1a22985a9ee839", + "libs/VerbraucherBasisTrait.php": "10fd9f54283c378104bce11d623d5581558355d26fc2f85bf04b316542874507", + "libs/VerbraucherSchnittstelle.php": "6329d423dca7b551f01fd884204be45be43f92cd6da6e4faedf2c3c9b4654a15", + "libs/ManagerNetzfahrplanV4Trait.php": "e27a3595e4ed8cdb62e2561149ef6a50adb9194e0239eb25c1216aee2716fa88", + "libs/ManagerNetzfahrplanV4DatenTrait.php": "59ae0ae283839ca82c58db9c071dcf28d0ab95e44f7a74c6473aca837ce416c4", + "libs/NetzfahrplanV4Datenarchiv.php": "57ef7a60c273c5270b3e93ff5528d7d294e708725c9edfe81ac7ed819587f983", + "libs/NetzfahrplanV4Messaufnahme.php": "ebe873bb714041e505a4f500b34c9bae3a5a0b93ac459161584ced758507a6e9", + "libs/NetzfahrplanV4Bilanzierung.php": "ffda2a03dbb226a4405a2ad66704c080a9dc475ce64282ad3f0d7d60034ab199", + "libs/NetzfahrplanV4Betriebsdaten.php": "6ff7d5710995778e7f941020a6f18555307ef51f16867f13efc204915e6dc9d9", + "libs/NetzfahrplanV4Bezugszaehler.php": "7aa01ce83a343eb767a889575fa04cece7f1c65cda347723e24dd68da40cea9a", + "libs/ManagerEnergieTrait.php": "2f89b0ad31ee6dd99180a7225173036eb6212b2ea7157e57fee23f0f8b441a51", + "libs/EnergieMessung.php": "16c64e1cbefde81c25f913aaca6546f0d30cc8f2fddecea0768a3760a98e97d2", + "libs/StoerungsSnapshot.php": "c871c1dd58d7fb1ef6c06985de066518688ef080f3cbdadda192d66cedcf49e1", + "libs/Anlagentopologie.php": "1b7e95d103a3048ed38c464a424f2f12821a147b269e149fd6b89bca84878c47", + "libs/Lizenzpruefung.php": "cb93cb49b93ed3ebb2060dba28b5b2dec2f29548d6ccff1afd9c7cb4624ad77e", + "libs/EinspeiseRegler.php": "8b982be5b95275c6dd6611a2bf2b1f89b991ba53cf94e46df0eed3249add65e0", + "libs/ManagerRegler.php": "608a4abebbe45da37a16316a530fa3f5ab21cb880bb341acbdcd3a9aaa93c6ee", + "libs/ManagerSchnittstelle.php": "0aae8c8c85eb252c577fcb0b5a50a12f3247e4a0bb083579122854f6c35878b3" + } +} diff --git a/examples/V4CorrectedFeedback/build_stage.py b/examples/V4CorrectedFeedback/build_stage.py new file mode 100644 index 0000000..e9ab97c --- /dev/null +++ b/examples/V4CorrectedFeedback/build_stage.py @@ -0,0 +1,25 @@ +"""Recreate a reviewed Lihrenmoos package from this commit; never install in Symcon.""" +from pathlib import Path +import hashlib,json,sys +here=Path(__file__).resolve().parent;repo=here.parents[1] +if len(sys.argv)!=2:raise SystemExit('Usage: python3 build_stage.py NEW_PRIVATE_STAGING_DIRECTORY') +target=Path(sys.argv[1]).resolve() +allowed=[Path('/srv/agent'),Path('/home/agent/services/qa')] +if not any(target!=p and target.is_relative_to(p) for p in allowed):raise SystemExit('Use a private agent staging directory, not live module paths') +if target.exists():raise SystemExit('Destination must not exist') +m=json.loads((here/'MANIFEST.json').read_text());contents={} +for n,v in m['files'].items(): + p=repo/n + if p.is_symlink() or not p.resolve().is_relative_to(repo):raise SystemExit('Unsafe source') + data=p.read_bytes() + if hashlib.sha256(data).hexdigest()!=v['after']:raise SystemExit('Reviewed source changed: '+n) + contents['source/'+n]=data +for n,v in m['dependencies'].items(): + if hashlib.sha256((repo/n).read_bytes()).hexdigest()!=v:raise SystemExit('Dependency changed: '+n) +for n in ['install.php','file_installer.php','MANIFEST.json','feedback-config.json']:contents[n]=(here/n).read_bytes() +if hashlib.sha256(contents['feedback-config.json']).hexdigest()!=m['configurationSha256']:raise SystemExit('Configuration changed') +target.mkdir(parents=True,mode=0o700) +for n,data in contents.items(): + p=target/n;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o640) +(target/'PACKAGE_HASHES.json').write_text(json.dumps({n:hashlib.sha256(v).hexdigest() for n,v in contents.items()},indent=2)+'\n') +print('Private staging package created:',target,'; no running module, settings or control changes.') diff --git a/examples/V4CorrectedFeedback/feedback-config.json b/examples/V4CorrectedFeedback/feedback-config.json new file mode 100644 index 0000000..4b5f04a --- /dev/null +++ b/examples/V4CorrectedFeedback/feedback-config.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "installationId": "e3a08f9e-af12-4695-99bd-8b51c0520021", + "assetId": "anlage01-virtual-ev", + "managerId": 17004, + "batteryInstanceId": 44234, + "mode": "virtual_split", + "allowEstimatedForTrial": false, + "maxSkewSeconds": 30, + "maxAgeSeconds": 60, + "idleToleranceW": 50.0, + "trackingToleranceW": 200.0, + "sources": [ + { + "key": "grid", + "variableId": 40348, + "parentId": 11490, + "ident": "Power_8", + "factorToW": 1000, + "role": "grid" + }, + { + "key": "physical_goodwe1", + "variableId": 47725, + "parentId": 19742, + "ident": "A_6_3_35182", + "factorToW": -1, + "role": "physical" + }, + { + "key": "physical_goodwe2", + "variableId": 35724, + "parentId": 57658, + "ident": "A_6_3_35182", + "factorToW": -1, + "role": "physical" + }, + { + "key": "physical_solaredge", + "variableId": 21447, + "parentId": 30789, + "ident": "Value", + "factorToW": 1, + "role": "physical" + }, + { + "key": "ev_requested", + "variableId": 19651, + "parentId": 58448, + "ident": "Nennleistung_Soll_EV", + "factorToW": 1.0, + "role": "ev_request" + }, + { + "key": "sdl_requested", + "variableId": 38943, + "parentId": 58448, + "ident": "Nennleistung_Soll_SDL", + "factorToW": 1.0, + "role": "sdl_request" + }, + { + "key": "gateway_active", + "role": "gateway_active", + "variableId": 23483, + "parentId": 58448, + "ident": "State", + "factorToW": 1.0 + } + ] +} diff --git a/examples/V4CorrectedFeedback/file_installer.php b/examples/V4CorrectedFeedback/file_installer.php new file mode 100644 index 0000000..a5ba371 --- /dev/null +++ b/examples/V4CorrectedFeedback/file_installer.php @@ -0,0 +1,66 @@ +$hash) { + if (!preg_match('~^libs/[A-Za-z0-9]+\.php$~D',$name) || is_link($target.'/'.$name) || hash_file('sha256',$target.'/'.$name)!==$hash) throw new RuntimeException('Abhaengigkeit wurde parallel geaendert: '.$name); + } + $before=[];$source=[];$already=true; + // Explicit dependency order. Both top-level modules are replaced only after all dependencies. + foreach($allowed as$name) { + $path=$target.'/'.$name;$candidate=$stage.'/source/'.$name; + if (is_link($path)||is_link($candidate)||realpath(dirname($path))!==$target.'/'.dirname($name)) throw new RuntimeException('Unerwarteter Dateipfad.'); + $source[$name]=(string)file_get_contents($candidate); + if (hash('sha256',$source[$name])!==$m['files'][$name]['after']) throw new RuntimeException('Paketpruefsumme geaendert: '.$name); + token_get_all($source[$name],TOKEN_PARSE); + $before[$name]=is_file($path)?file_get_contents($path):null; + $hash=$before[$name]===null?null:hash('sha256',$before[$name]); + if (!in_array($hash,[$m['files'][$name]['before'],$m['files'][$name]['after']],true)) throw new RuntimeException('Paralleler Modulstand; nichts ueberschrieben: '.$name); + if($hash!==$m['files'][$name]['after'])$already=false; + } + if($already)return ['status'=>'already_installed','backup'=>null]; + $backup=$stage.'/backups/'.gmdate('Ymd\THis\Z').'-'.bin2hex(random_bytes(4)); + if(!mkdir($backup,0700,true))throw new RuntimeException('Sicherung fehlgeschlagen.'); + foreach($allowed as$name) { + if($before[$name]!==null) { + $dest=$backup.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true); + v4FeedbackWrite($dest,$before[$name],0600); + } + } + v4FeedbackWrite($backup.'/MANIFEST.json',json_encode($m,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0600); + $written=[]; + try { + foreach($allowed as$name) { + if((is_file($target.'/'.$name)?file_get_contents($target.'/'.$name):null)!==$before[$name])throw new RuntimeException('Parallele Aenderung waehrend Installation.'); + if($before[$name]===$source[$name])continue; + v4FeedbackWrite($target.'/'.$name,$source[$name]);$written[]=$name; + } + } catch(Throwable $e) { + foreach(array_reverse($written)as$name) { + $p=$target.'/'.$name; + if(!is_link($p)&&is_file($p)&&hash_file('sha256',$p)===$m['files'][$name]['after']) { + if($before[$name]===null)unlink($p);else v4FeedbackWrite($p,$before[$name]); + } + } + throw $e; + } + return ['status'=>'installed','backup'=>$backup,'filesChanged'=>$written]; +} diff --git a/examples/V4CorrectedFeedback/install.php b/examples/V4CorrectedFeedback/install.php new file mode 100644 index 0000000..7f01294 --- /dev/null +++ b/examples/V4CorrectedFeedback/install.php @@ -0,0 +1,68 @@ +'corrected_feedback_trial_disabled','startedAt'=>gmdate('c'),'actuatorPermissionGranted'=>false,'serverUpdateRequired'=>false]; +try { + $hashes=json_decode((string)file_get_contents(__DIR__.'/PACKAGE_HASHES.json'),true,32,JSON_THROW_ON_ERROR); + foreach($hashes as$name=>$hash) { + if(str_contains($name,'..')||str_starts_with($name,'/')||is_link(__DIR__.'/'.$name)||hash_file('sha256',__DIR__.'/'.$name)!==$hash)throw new RuntimeException('Vorbereitetes Paket veraendert.'); + } + foreach([$manager=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}',$battery=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']as$id=>$module) { + if(!IPS_InstanceExists($id)||IPS_GetInstance($id)['ModuleInfo']['ModuleID']!==$module)throw new RuntimeException('Modulzuordnung ungueltig.'); + if(IPS_HasChanges($id))throw new RuntimeException('Offene Aenderungen zuerst speichern oder verwerfen.'); + } + $beforeM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR); + $beforeB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR); + if(($beforeM['NetzfahrplanAktiv']??null)!==false || ($beforeM['NetzfahrplanV4RegeltestErlaubt']??false)!==false || ($beforeB['NetzfahrplanV4RegeltestErlaubt']??false)!==false)throw new RuntimeException('Alte Fahrplanausgabe und beide Regeltestfreigaben muessen AUS bleiben.'); + $c=json_decode((string)file_get_contents(__DIR__.'/feedback-config.json'),true,32,JSON_THROW_ON_ERROR); + $manifest=json_decode((string)file_get_contents(__DIR__.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR); + if(($c['managerId']??null)!==$manager || ($c['batteryInstanceId']??null)!==$battery || ($c['allowEstimatedForTrial']??null)!==false + || hash_file('sha256',__DIR__.'/feedback-config.json')!==$manifest['configurationSha256'])throw new RuntimeException('Falsche Rueckmeldekonfiguration.'); + $desired=json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION); + $oldMapping=$beforeB['NetzfahrplanV4RueckmeldungKonfiguration']??'{}'; + if(!in_array($oldMapping,['','{}',$desired],true))throw new RuntimeException('Abweichende Rueckmeldekonfiguration nicht ueberschrieben.'); + $controls=[];foreach(IPS_GetModuleList()as$module)if((IPS_GetModule($module)['Prefix']??'')==='MC')$controls=array_merge($controls,IPS_GetInstanceListByModuleID($module)); + if(count($controls)!==1||!function_exists('MC_ReloadModule'))throw new RuntimeException('Module Control nicht eindeutig.'); + $locked=IPS_SemaphoreEnter('ENELIX.V4.FeedbackInstall',3000);if(!$locked)throw new RuntimeException('Installation bereits aktiv.'); + $report['files']=v4FeedbackFiles(__DIR__,'/var/lib/symcon/modules/Enelix-EMS'); + if(MC_ReloadModule($controls[0],'Enelix-EMS')===false)throw new RuntimeException('Bibliotheks-Reload fehlgeschlagen.'); + $afterM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR); + $afterB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR); + foreach([[$beforeM,$afterM],[$beforeB,$afterB]]as$pair)foreach($pair[0]as$key=>$value)if(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value)throw new RuntimeException('Bestehender Parameter beim Reload veraendert: '.$key); + if(!array_key_exists('NetzfahrplanV4RueckmeldungKonfiguration',$afterB)||!array_key_exists('NetzfahrplanV4RegeltestErlaubt',$afterM) + ||!function_exists('ENELIX_GetV4BatterieRueckmeldung')||!function_exists('ENELIX_GetV4ManagerTestStatus')) { + $report['status']='waiting_for_registration'; + echo "Dateien installiert. Modulregistrierung wird abgeschlossen; diesen Aufruf einmal wiederholen. Keine Stellfreigabe.\n"; + } else { + if($afterM['NetzfahrplanV4RegeltestErlaubt']!==false||$afterB['NetzfahrplanV4RegeltestErlaubt']!==false)throw new RuntimeException('Unerwartete Testfreigabe.'); + if($afterB['NetzfahrplanV4RueckmeldungKonfiguration']!==$desired) { + IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$desired);$changedMapping=true;IPS_ApplyChanges($battery); + } + $finalM=json_decode(IPS_GetConfiguration($manager),true,128,JSON_THROW_ON_ERROR); + $finalB=json_decode(IPS_GetConfiguration($battery),true,128,JSON_THROW_ON_ERROR); + foreach([[$beforeM,$finalM],[$beforeB,$finalB]]as$pair)foreach($pair[0]as$key=>$value)if($key!=='NetzfahrplanV4RueckmeldungKonfiguration'&&(!array_key_exists($key,$pair[1])||$pair[1][$key]!==$value))throw new RuntimeException('Bestehender Parameter unerwartet veraendert: '.$key); + if(($finalM['NetzfahrplanAktiv']??null)!==false||($finalM['NetzfahrplanV4RegeltestErlaubt']??null)!==false||($finalB['NetzfahrplanV4RegeltestErlaubt']??null)!==false)throw new RuntimeException('Unerwartete Stellfreigabe nach Installation.'); + $f=json_decode(ENELIX_GetV4BatterieRueckmeldung($battery),true,32,JSON_THROW_ON_ERROR); + $report['feedback']=array_intersect_key($f,array_flip(['status','kind','method','reason','reasons','estimated','batteryW','gridW','sourceOldestAt','checkedAt','configHash','usableForTrial','canDispatch'])); + $report['status']='corrected_feedback_installed_trial_disabled';$report['originalParametersPreserved']=true; + echo "OK: Korrigierte physische Rueckmeldung mit Manager-Vorschau und Batterietreiber verbunden.\n"; + echo 'Rueckmeldung: '.($f['status']??'available').'; Modellanteil: '.(($f['estimated']??false)?'ja':'nein').".\n"; + if(isset($f['reason']))echo 'Aktueller Hinweis: '.$f['reason']."\n"; + echo "Beide Regeltestfreigaben AUS. Keine V4-Ausgabe aktiviert; vorhandene Regelung, Energiekonten und Datensender bleiben bestehen.\n"; + } +} catch(Throwable $e) { + $report['status']='needs_review';$report['reason']=substr($e->getMessage(),0,260); + if($changedMapping && $oldMapping!==null) { + try{IPS_SetProperty($battery,'NetzfahrplanV4RueckmeldungKonfiguration',$oldMapping);IPS_ApplyChanges($battery);$report['mappingRestored']=true;}catch(Throwable $ignored){$report['mappingRestored']=false;} + } + echo 'FEHLER: '.$report['reason']."\nKeine V4-Stellfreigabe erteilt.\n"; +} finally { + $report['finishedAt']=gmdate('c');v4FeedbackWrite(__DIR__.'/INSTALL_RESULT.json',json_encode($report,JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT),0644); + if($locked)IPS_SemaphoreLeave('ENELIX.V4.FeedbackInstall'); + echo 'FEEDBACK INSTALL REPORT: '.__DIR__."/INSTALL_RESULT.json\n"; +} diff --git a/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php new file mode 100644 index 0000000..da72811 --- /dev/null +++ b/libs/BatterieNetzfahrplanV4RueckmeldungTrait.php @@ -0,0 +1,38 @@ +RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}'); + } + + private function v4BatterieRueckmeldung(): array + { + $json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration'); + if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large'); + $c=json_decode($json,true,32,JSON_THROW_ON_ERROR); + if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid'); + if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid'); + return NetzfahrplanV4Rueckmeldung::read($c,static function(int $id):array { + if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing'); + $v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id); + return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']]; + },time()); + } + + /** Read only. A returned current value is NOT actuator permission. */ + public function GetV4BatterieRueckmeldung(): string + { + try { $r=$this->v4BatterieRueckmeldung(); } + catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200), + 'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false]; } + return json_encode($r,JSON_THROW_ON_ERROR); + } +} diff --git a/libs/BatterieNetzfahrplanV4TestTrait.php b/libs/BatterieNetzfahrplanV4TestTrait.php index 56a20d2..f12a22b 100644 --- a/libs/BatterieNetzfahrplanV4TestTrait.php +++ b/libs/BatterieNetzfahrplanV4TestTrait.php @@ -8,12 +8,15 @@ use RuntimeException; use Throwable; require_once __DIR__ . '/NetzfahrplanV4Regeltest.php'; +require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php'; /** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */ trait BatterieNetzfahrplanV4TestTrait { + use BatterieNetzfahrplanV4RueckmeldungTrait; private function registriereV4BatterieTest(): void { + $this->registriereV4Rueckmeldung(); $this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false); $this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', ''); $this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', ''); @@ -69,6 +72,7 @@ trait BatterieNetzfahrplanV4TestTrait if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) { throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.'); } + NetzfahrplanV4Rueckmeldung::requireTrial($this->v4BatterieRueckmeldung(), $s, $now); $this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']); $s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now; $s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now; @@ -157,6 +161,8 @@ trait BatterieNetzfahrplanV4TestTrait $s = $this->v4BatterieTestSitzung();$mono = hrtime(true) / 1e9; try { if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Lokale Testfreigabe entzogen.'); + $feedback = $this->v4BatterieRueckmeldung(); + NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now); $c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true); if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.'); if (!$c) { @@ -175,6 +181,13 @@ trait BatterieNetzfahrplanV4TestTrait if ($last * $w < 0) $w = 0; elseif (abs($w) > abs($last)) $w = $last; } + // Final hardware-policy/ramp clipping must not invalidate the binding or grid limits. + if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed'); + $expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w; + if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1) + || ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) { + throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit'); + } $s['lastWallClockAt'] = $now; $this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR)); return $w; diff --git a/libs/ManagerNetzfahrplanV4EmpfangTrait.php b/libs/ManagerNetzfahrplanV4EmpfangTrait.php index 0761567..a6fde3f 100644 --- a/libs/ManagerNetzfahrplanV4EmpfangTrait.php +++ b/libs/ManagerNetzfahrplanV4EmpfangTrait.php @@ -8,6 +8,7 @@ use RuntimeException; use Throwable; require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php'; +require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php'; /** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */ trait ManagerNetzfahrplanV4EmpfangTrait @@ -158,6 +159,31 @@ trait ManagerNetzfahrplanV4EmpfangTrait $topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR); $actual = [];$now = time(); foreach ($topology as $asset) { + $controllers=[]; + foreach ($this->aktiveVerbraucherIDs() as $controller) { + if (IPS_InstanceExists($controller) + && IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}' + && IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null) + && IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller; + } + if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous'); + if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) { + $controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration'); + if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) { + if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required'); + $f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR); + if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID') + || ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID + || ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null) + || !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null) || $now-$f['checkedAt']>2 || $f['checkedAt']>$now) { + throw new RuntimeException('feedback_invalid_no_legacy_fallback'); + } + $actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW']; + $d['operation']['localFeedback'][$asset['ID']]=$f; + continue; + } + } + // Legacy value remains display-only until an explicit physical feedback mapping exists. $id = (int) ($asset['LeistungVariableID'] ?? 0); if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.'); $before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id); @@ -189,6 +215,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait $checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'], $this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']); $out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time()); + $out['localFeedback'] = $operation['localFeedback'] ?? []; + $out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial'; $out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true); } catch (Throwable $error) { $out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)]; diff --git a/libs/ManagerNetzfahrplanV4TestTrait.php b/libs/ManagerNetzfahrplanV4TestTrait.php index 7a052e4..aaf419e 100644 --- a/libs/ManagerNetzfahrplanV4TestTrait.php +++ b/libs/ManagerNetzfahrplanV4TestTrait.php @@ -78,6 +78,8 @@ trait ManagerNetzfahrplanV4TestTrait 'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000), 'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000), 'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'), + 'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '', + 'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'], 'sequence' => 1, 'lastWallClockAt' => $now, 'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now]; // Validate everything BEFORE arming either module. This rejects all normal shadow replies. @@ -156,6 +158,7 @@ trait ManagerNetzfahrplanV4TestTrait } if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.'); $op['gridW'] += $otherDelta; + $op['localPredictedOtherDeltaW']=$otherDelta; if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) { throw new RuntimeException('Kein gueltiger empfangener Testkontext.'); } diff --git a/libs/NetzfahrplanV4Regeltest.php b/libs/NetzfahrplanV4Regeltest.php index a377053..00e6d4f 100644 --- a/libs/NetzfahrplanV4Regeltest.php +++ b/libs/NetzfahrplanV4Regeltest.php @@ -7,6 +7,7 @@ namespace Belevo\EnelixEMS; use InvalidArgumentException; require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php'; +require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php'; /** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */ final class NetzfahrplanV4Regeltest @@ -87,6 +88,11 @@ final class NetzfahrplanV4Regeltest if (($a['controlContext'] ?? null) != $plan['controlContext']) { throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.'); } + $feedback = $operation['localFeedback'][$a['assetId']] ?? []; + NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now); + if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot'); + $otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9); + if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot'); $preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now); $charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, self::MAX_TEST_POWER_W), self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, self::MAX_TEST_POWER_W)); @@ -109,6 +115,9 @@ final class NetzfahrplanV4Regeltest 'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge, 'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'], 'revision' => $plan['configRevision'], 'expectedGridW' => $grid, + 'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'], + 'feedbackSourceOldestAt' => $feedback['sourceOldestAt'], + 'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'], 'executionMeaning' => 'separate_local_control_trial_not_shadow_plan_execution']; } @@ -139,6 +148,23 @@ final class NetzfahrplanV4Regeltest if ($c > $session['maxChargeW'] || $d > $session['maxDischargeW'] || $w > $c || $w < -$d) { throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.'); } + if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash']) + || $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid'); + foreach (['importLimitW','exportLimitW'] as $limit) { + if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing'); + if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9); + } + $l=$session['gridLimits'] ?? null; + if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing'); + $month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n'); + $import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null; + if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly); + foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) { + if ($limit!==null) { + self::number($limit,$key,0,1e9); + if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit'); + } + } self::uuid($command['sourceShadowPlanId'] ?? null); } diff --git a/libs/NetzfahrplanV4Rueckmeldung.php b/libs/NetzfahrplanV4Rueckmeldung.php new file mode 100644 index 0000000..1ecd110 --- /dev/null +++ b/libs/NetzfahrplanV4Rueckmeldung.php @@ -0,0 +1,141 @@ +$hi) { + throw new InvalidArgumentException('feedback_numeric_invalid'); + } + return (float)$v; + } + + private static function ordered(array $v): array + { + if (!array_is_list($v)) ksort($v, SORT_STRING); + foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x); + unset($x);return $v; + } + + public static function configuration(array $c): array + { + $keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial', + 'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources']; + if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1 + || !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId']) + || !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100 + || !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) { + throw new InvalidArgumentException('feedback_configuration_invalid'); + } + foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid'); + if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid'); + foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) { + if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid'); + } + $c['idleToleranceW']=self::number($c['idleToleranceW'],0,100); + $c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500); + if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid'); + $ids=[];$names=[];$roles=[]; + foreach ($c['sources'] as &$s) { + $fields=['key','role','variableId','parentId','ident','factorToW']; + if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s)) + || !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key']) + || isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true) + || !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']]) + || !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid'); + $s['factorToW']=self::number($s['factorToW'],-1e6,1e6); + if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid'); + $ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1; + } + unset($s); + if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required'); + foreach (['ev_request','sdl_request','gateway_active'] as $role) { + if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required'); + } + usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key'])); + return self::ordered($c); + } + + public static function fingerprint(array $c): string + { + return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION)); + } + + /** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */ + public static function read(array $config, callable $reader, int $now): array + { + $c=self::configuration($config); + if ($now<1) throw new InvalidArgumentException('feedback_time_invalid'); + $first=[];$values=[];$times=[];$physicalTimes=[];$changed=[]; + foreach ($c['sources'] as $s) { + $r=$reader($s['variableId']); + if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident'] + || !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now + || !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']); + $first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident'])); + if ($s['role']==='gateway_active') { + if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive'); + } else { + $w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW']; + $values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9); + } + // An unchanged command is held STATE, not proof of a fresh sensor or an actuator watchdog. + if (in_array($s['role'],['grid','physical'],true)) { + if ($now-$r['updated']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']); + $times[]=$r['updated'];if ($s['role']==='physical') $physicalTimes[]=$r['updated']; + } else $changed[$s['role']]=$r['changed']; + } + foreach ($c['sources'] as $s) { + $second=$reader($s['variableId']); + if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident']))!==$first[$s['key']]) { + throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']); + } + } + if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew'); + $physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null; + if ($estimated) { + if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed'); + $ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl; + if ($sdl===0.0) { + // No external commanded contribution. Physical response may lag a NEW EV command. + $power=$physical; + if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power'); + } else { + if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed'); + if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking'); + $power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power; + if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable'; + } + if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial'; + } + return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'], + 'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now, + 'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes), + 'sourceSkewSeconds'=>max($times)-min($times),'maxAgeSeconds'=>$c['maxAgeSeconds'], + 'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical, + 'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking, + 'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[], + 'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum', + 'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false]; + } + + public static function requireTrial(array $f, array $binding, int $now): void + { + foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) { + if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding'); + } + if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true + || !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2 + || !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null) + || $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds'] + || !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial'); + if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed'); + self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9); + } +} diff --git a/tests/NetzfahrplanV4BilanzierungTest.php b/tests/NetzfahrplanV4BilanzierungTest.php index 6e1145a..2f716db 100644 --- a/tests/NetzfahrplanV4BilanzierungTest.php +++ b/tests/NetzfahrplanV4BilanzierungTest.php @@ -11,6 +11,6 @@ final class NetzfahrplanV4BilanzierungTest extends TestCase $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); + self::assertStringContainsString('TOTAL 42 checks passed',$out); } } diff --git a/tests/NetzfahrplanV4DatenTest.php b/tests/NetzfahrplanV4DatenTest.php index d4c69b9..3de873c 100644 --- a/tests/NetzfahrplanV4DatenTest.php +++ b/tests/NetzfahrplanV4DatenTest.php @@ -16,6 +16,6 @@ final class NetzfahrplanV4DatenTest extends TestCase $out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]); fclose($pipes[1]);fclose($pipes[2]); self::assertSame(0,proc_close($process),$out.$err); - self::assertStringContainsString('TOTAL 21 application-data checks passed',$out); + self::assertStringContainsString('TOTAL 27 application-data checks passed',$out); } } diff --git a/tests/NetzfahrplanV4RegeltestTest.php b/tests/NetzfahrplanV4RegeltestTest.php index a49725f..64dee03 100644 --- a/tests/NetzfahrplanV4RegeltestTest.php +++ b/tests/NetzfahrplanV4RegeltestTest.php @@ -19,6 +19,6 @@ final class NetzfahrplanV4RegeltestTest extends TestCase fclose($pipes[1]);fclose($pipes[2]); self::assertSame(0, proc_close($process), $out . $err); self::assertSame('', $err); - self::assertStringContainsString('TOTAL 59 checks passed.', $out); + self::assertStringContainsString('TOTAL 71 checks passed.', $out); } } diff --git a/tests/NetzfahrplanV4RueckmeldungTest.php b/tests/NetzfahrplanV4RueckmeldungTest.php new file mode 100644 index 0000000..d975f5b --- /dev/null +++ b/tests/NetzfahrplanV4RueckmeldungTest.php @@ -0,0 +1,25 @@ +['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 ',$out); + } + public function testPhysicalFeedback(): void {$this->checkScript('checks.php');} + public function testActualReceiverIntegration(): void {$this->checkScript('receiver_checks.php');} + public function testActualFallbackWrapper(): void {$this->checkScript('fallback_checks.php');} + public function testMessageWithoutVisibleDiagnostics(): void {$this->checkScript('message_checks.php');} +} diff --git a/tests/V4ControlTrial/checks.php b/tests/V4ControlTrial/checks.php index eb0d903..d9cfae7 100644 --- a/tests/V4ControlTrial/checks.php +++ b/tests/V4ControlTrial/checks.php @@ -14,6 +14,11 @@ namespace Belevo\EnelixEMS { function IPS_GetInstance($id) { return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201, 'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']]; } function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; } + function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); } + function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; } + function IPS_GetObject($id) { return ['ParentID'=>200,'ObjectIdent'=>'source'.$id]; } + function GetValue($id) { return $id===50001?$GLOBALS['objects'][17004]->op['gridW']:$GLOBALS['objects'][17004]->actual['b']; } + function IPS_RequestAction($id,$key,$value) { $GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id]; if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value); @@ -76,7 +81,10 @@ namespace Belevo\EnelixEMS { private function berechtigungLizenziert($k) { return $this->licensed; } private function actieveDummy() {} private function aktiveVerbraucherIDs() { return [44234]; } - private function netzfahrplanV4LokaleVorschauwerte() { return [$this->op,$this->actual]; } + private function netzfahrplanV4LokaleVorschauwerte() { + $op=$this->op;$f=json_decode($GLOBALS['objects'][44234]->GetV4BatterieRueckmeldung(),true); + $op['localFeedback']=['b'=>$f];return [$op,$this->actual]; + } private function regeln($renew) { $this->fallback++; } public function tick(&$allocation,$consumers=null,$sync=true) { $this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync); @@ -105,7 +113,14 @@ namespace { 'maxChargeW'=>5000.,'maxDischargeW'=>5000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog', 'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']]; $r['controlledTrial']=$serverAuthority?$a:null; - $m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b]; + $m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];$GLOBALS['feedbackAt']=[]; + $fc=['version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234, + 'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60, + 'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]]; + foreach ([50001=>'grid',50002=>'physical'] as $vid=>$role) $fc['sources'][]=['key'=>'source'.$vid,'role'=>$role,'variableId'=>$vid,'parentId'=>200,'ident'=>'source'.$vid,'factorToW'=>1.0]; + $b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($fc); + $op['localFeedback']=['b'=>json_decode($b->GetV4BatterieRueckmeldung(),true)]; + $m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions; $b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog'; $start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>5000.,'maxDischargeW'=>5000.]; @@ -157,5 +172,6 @@ namespace { $bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease'); $bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance'); require __DIR__ . '/additional_checks.php'; + require __DIR__ . '/feedback_checks.php'; echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n"; } diff --git a/tests/V4ControlTrial/feedback_checks.php b/tests/V4ControlTrial/feedback_checks.php new file mode 100644 index 0000000..00c384e --- /dev/null +++ b/tests/V4ControlTrial/feedback_checks.php @@ -0,0 +1,22 @@ +props['NetzfahrplanV4RueckmeldungKonfiguration']='{}'; +rejects(fn()=>start($m,$s),'trial cannot use old filtered feedback when mapping missing');check($b->writes===[],'missing corrected feedback sends no command'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61; +$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'physical feedback expiry returns battery to normal allocation'); +check(end($b->writes)===0,'expired physical feedback revokes prior command'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock-61;$b->watchdog(); +check(!$b->active()&&end($b->writes)===0,'independent battery read catches staleness without manager tick'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=json_decode($b->props['NetzfahrplanV4RueckmeldungKonfiguration'],true);$c['trackingToleranceW']=250; +$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($c);$b->watchdog(); +check(!$b->active()&&end($b->writes)===0,'mapping revision change revokes active battery session'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['feedbackConfigHash']=str_repeat('f',64); +rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'changed feedback command binding rejected'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();check(isset($c['feedbackConfigHash'],$c['feedbackSourceOldestAt'])&&array_key_exists('importLimitW',$c),'command includes source provenance and limits'); +$c['sequence']++;unset($c['exportLimitW']);rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'missing explicit grid limit not interpreted as unlimited'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['importLimitW']=1000.;$c['watts']=0; +$m->op['gridW']=1500.;$b->measurements['Ladezustand']=15.; +rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'final device clipping cannot silently violate accepted grid boundary'); +check(!$b->active()&&end($b->writes)===0,'unreachable final limit aborts software trial'); +[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock+1;tick($m); +check(!$m->active(),'future physical sample is not smoothed into control'); diff --git a/tests/V4Feedback/checks.php b/tests/V4Feedback/checks.php new file mode 100644 index 0000000..06cb6e7 --- /dev/null +++ b/tests/V4Feedback/checks.php @@ -0,0 +1,58 @@ +1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234, + 'mode'=>$mode,'allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]]; + $roles=['grid','physical','physical'];if($mode==='virtual_split')$roles=array_merge($roles,['ev_request','sdl_request','gateway_active']);$values=[3000.0,-2000.0,-1000.0,-3000.0,0.0,true];$r=[]; + foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id];} + return [$c,$r,$now]; +} +function readit($c,$r,$n){return F::read($c,fn($id)=>$r[$id],$n);} +[$c,$r,$n]=fx();$f=readit($c,$r,$n); +check($f['batteryW']===-3000.0&&$f['gridW']===3000.0,'physical total and grid signs retained'); +check($f['usableForTrial']&&!$f['estimated']&&!$f['canDispatch'],'usable feedback alone never grants dispatch'); +F::requireTrial($f,$c,$n);check(true,'matching trial binding accepted'); +check(F::fingerprint($c)===F::fingerprint(json_decode(json_encode($c),true)),'configuration hash stable over numeric property roundtrip'); +$d=$c;$d['sources']=array_reverse($d['sources']);check(F::fingerprint($c)===F::fingerprint($d),'source list order canonical'); +$d['trackingToleranceW']=201;check(F::fingerprint($c)!==F::fingerprint($d),'changed tolerance invalidates session hash'); +foreach([true,'1',NAN,INF,null]as$bad){$x=$r;$x[101]['value']=$bad;fails(fn()=>readit($c,$x,$n),'invalid physical number '.gettype($bad));} +$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;fails(fn()=>readit($c,$x,$n),'physical age not refreshed by read'); +$x=$r;$x[101]['updated']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent grid battery timestamps rejected'); +$x=$r;$x[101]['updated']=$n+1;fails(fn()=>readit($c,$x,$n),'future original sample rejected'); +$x=$r;$x[101]['parentID']=201;fails(fn()=>readit($c,$x,$n),'reused variable wrong parent rejected'); +$x=$r;$x[101]['ident']='other';fails(fn()=>readit($c,$x,$n),'reused variable wrong ident rejected'); +$d=$c;$d['sources'][1]['variableId']=$d['sources'][0]['variableId'];fails(fn()=>F::configuration($d),'duplicate physical source rejected'); +$d=$c;$d['maxAgeSeconds']=300;fails(fn()=>F::configuration($d),'historical freshness not accepted for live control'); +$calls=[];fails(function()use($c,$r,$n,&$calls){F::read($c,function($id)use($r,&$calls){$calls[$id]=($calls[$id]??0)+1;$x=$r[$id];if($calls[$id]>1)$x['value']+=1;return$x;},$n);},'same-second change during double read rejected'); +$d=$c;$d['sources'][1]['factorToW']=-1;check(readit($d,$r,$n)['batteryW']===1000.0,'vendor sign conversion once'); +$f=readit($c,$r,$n);fails(fn()=>F::requireTrial($f,$c,$n+3),'old validated snapshot not reused'); +$d=$c;$d['managerId']=20000;fails(fn()=>F::requireTrial($f,$d,$n),'different manager cannot use feedback'); +$d=$c;$d['feedbackConfigHash']=str_repeat('a',64);fails(fn()=>F::requireTrial($f,$d,$n),'changed mapping aborts trial'); +[$c,$r,$n]=fx('virtual_split');$r[103]['updated']=$r[104]['updated']=$r[105]['updated']=$n-86400;$r[103]['changed']=$r[104]['changed']=$r[105]['changed']=$n-172800; +$r[101]['value']=$r[102]['value']=$r[103]['value']=$r[104]['value']=0.0;$f=readit($c,$r,$n); +check($f['batteryW']===0.0&&$f['estimated'],'unchanged idle commands need no artificial heartbeat'); +check(!$f['usableForTrial'],'virtual estimate requires explicit acceptance'); +$c['allowEstimatedForTrial']=true;$f=readit($c,$r,$n);check($f['usableForTrial']&&!$f['gatewayWatchdogVerified'],'idle acceptance is not gateway watchdog proof'); +$r[101]['value']=20.0;$f=readit($c,$r,$n);check($f['batteryW']===20.0,'idle residual not clipped to zero'); +$r[101]['value']=60.0;fails(fn()=>readit($c,$r,$n),'unexplained idle power refused'); +$r[101]['value']=-2000.0;$r[102]['value']=-1000.0;$r[103]['value']=2000.0;$r[103]['updated']=$n;$r[103]['changed']=$n; +$f=readit($c,$r,$n);check($f['batteryW']===-3000.0,'single EV feedback follows physics not desired sign or held filter'); +$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['updated']=$n-1; +fails(fn()=>readit($c,$r,$n),'SDL transition after physical sample cannot be guessed'); +[$c,$r,$n]=fx('virtual_split');$c['allowEstimatedForTrial']=true;$r[103]['value']=3000.0;$r[104]['value']=1000.0;$r[101]['value']=1900.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n); +check($f['batteryW']===2925.0&&$f['externalModelW']===975.0,'small tracking error allocated but explicitly estimated'); +check(!$f['separatelyMeasured']&&$f['heldFilterUsed']===false,'partition never labelled separately measured'); +$r[101]['value']=1000.0;fails(fn()=>readit($c,$r,$n),'large tracking error prevents stale virtual correction'); +$r[103]['value']=42000.0;$r[104]['value']=-40000.0;$r[101]['value']=0.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n); +check(!$f['usableForTrial']&&in_array('opposing_virtual_flows_not_identifiable',$f['reasons'],true),'counterflow cannot be independently attributed'); +$r[105]['value']=false;fails(fn()=>readit($c,$r,$n),'disabled gateway blocks feedback'); +$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php'); +check(!preg_match('/\b(?:SetValue|IPS_RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$src),'pure feedback has no control side effects'); +echo "TOTAL $count physical-feedback checks passed. Synthetic source readings only.\n"; diff --git a/tests/V4Feedback/fallback_checks.php b/tests/V4Feedback/fallback_checks.php new file mode 100644 index 0000000..efe7d87 --- /dev/null +++ b/tests/V4Feedback/fallback_checks.php @@ -0,0 +1,23 @@ +$GLOBALS['instance']->status];} +eval('class FallbackHarness { const STATUS_AKTIV=102; public $InstanceID=17004,$trial=true,$active=true,$status=102,$abortFirst=true,$throwFirst=false,$passes=[],$attributes=[],$stops=[];'. + 'public function run(){ $this->regeln(false); } private function v4ManagerTestSession(){return $this->trial?[1]:[];} private function GetValue($k){return $this->active;}'. + 'private function WriteAttributeString($k,$v){$this->attributes[$k]=$v;} private function v4ManagerTestAbbrechen($s){$this->stops[]=$s;$this->trial=false;}'. + 'private function regelnIntern(bool $r):void { $this->passes[]=$r;if(count($this->passes)==1){if($this->throwFirst)throw new RuntimeException("synthetic_failure");if($this->abortFirst)$this->trial=false;}}'. $method.'}'); +$count=0;function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";} +$instance=new FallbackHarness();$instance->run();ck($instance->passes===[false,true],'trial abort performs exactly one fresh ordinary recomputation'); +ck($instance->attributes['LetzteSollwerte']==='{}','pre-trial cached target is discarded before fallback');ck(!$locked,'control semaphore released after fallback'); +$instance=new FallbackHarness();$instance->trial=false;$instance->run();ck($instance->passes===[false],'ordinary control path does not gain extra passes'); +$instance=new FallbackHarness();$instance->abortFirst=false;$instance->run();ck($instance->passes===[false],'valid trial not overridden by ordinary pass'); +$instance=new FallbackHarness();$instance->status=201;$instance->run();ck($instance->passes===[false],'invalid measurement status prevents forced ordinary recomputation'); +$instance=new FallbackHarness();$instance->throwFirst=true;try{$instance->run();}catch(RuntimeException $e){} +ck(!$locked&&!$instance->trial&&count($instance->stops)===1,'exception revokes trial and releases semaphore without endless retries'); +echo "TOTAL $count actual manager fallback-wrapper checks passed. No real IPS runtime.\n"; diff --git a/tests/V4Feedback/message_checks.php b/tests/V4Feedback/message_checks.php new file mode 100644 index 0000000..0c85b13 --- /dev/null +++ b/tests/V4Feedback/message_checks.php @@ -0,0 +1,23 @@ +'1'];public bool $valid=true; + protected function GetBuffer($k){return $this->buffers[$k]??'';} + protected function GetValue($k){throw new RuntimeException('No diagnostic variables exist: '.$k);} + protected function ReadAttributeBoolean($k){return $k==='ZustandLadezustandGueltig'?$this->valid:false;} + protected function ReadAttributeInteger($k){return 0;} + protected function ReadAttributeFloat($k){return 0.0;} + protected function ReadAttributeString($k){return $k==='Leistungsangebot'?'[0]':($k==='Betriebsart'?'Auto':'');} + protected function ReadPropertyInteger($k){return 1;} +} +require __DIR__.'/../../Batterie/module.php'; +use Belevo\EnelixEMS\Nachrichtenvertrag as N; +$b=new Batterie();$read=new ReflectionMethod(Batterie::class,'baueVerbraucherdaten');$count=0; +function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";} +$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'partition source quality independent of hidden diagnostic variables'); +$b->buffers['V4FeedbackEstimated']='0';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_GEMESSEN,'ordinary physical source marked measured'); +$b->valid=false;$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_NICHT_VORHANDEN&&$d['Istleistung_W']===null,'invalid sample never published as measured'); +$b->valid=true;$b->buffers['V4FeedbackEstimated']='1';$b->buffers['V4BatteryTrialSession']='{}';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'last estimate not relabelled measured immediately after trial ends'); +$source=file_get_contents(__DIR__.'/../../Batterie/module.php');ck(str_contains($source,"SetBuffer('V4FeedbackEstimated', (\$messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0')"),'fresh measurement updates internal source quality'); +echo "TOTAL $count actual message-builder checks passed. No real variables or writes.\n"; diff --git a/tests/V4Feedback/receiver_checks.php b/tests/V4Feedback/receiver_checks.php new file mode 100644 index 0000000..f617f4b --- /dev/null +++ b/tests/V4Feedback/receiver_checks.php @@ -0,0 +1,43 @@ +['ModuleID'=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']];} + function IPS_GetProperty($id,$k){return ['IstleistungVariableID'=>1002,'LadezustandVariableID'=>1001,'NetzfahrplanV4RueckmeldungKonfiguration'=>$GLOBALS['configured']?'configured':'{}'][$k];} + function IPS_VariableExists($id){return $id===1002;} + function IPS_GetVariable($id){return ['VariableUpdated'=>$GLOBALS['now']];} + function GetValue($id){return -9000.;} // deliberately held/incorrect legacy virtual display + class FeedbackReceiver { + use ManagerNetzfahrplanV4EmpfangTrait; + public int $InstanceID=17004;public array $props,$attrs,$op; + public function __construct($r,$op){$this->op=$op;$this->props=['NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}'];} + private function ReadPropertyBoolean($k){return $this->props[$k];} + private function ReadPropertyString($k){return $this->props[$k];} + private function ReadPropertyInteger($k){return $this->props[$k];} + private function ReadAttributeString($k){return $this->attrs[$k];} + private function aktiveVerbraucherIDs(){return [44234];} + private function berechtigungLizenziert($k){return true;} + public function GetNetzfahrplanV4Diagnose(){return json_encode(['operation'=>$this->op]);} + } +} +namespace { + function ENELIX_GetV4BatterieRueckmeldung($id){return json_encode($GLOBALS['feedback']);} + require_once __DIR__.'/../V4Receiver/fixture.php'; + use Belevo\EnelixEMS\FeedbackReceiver; + [$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0; + $feedback=['kind'=>'local_physical_feedback','version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,'batteryW'=>0.,'gridW'=>$op['gridW'],'checkedAt'=>$now,'estimated'=>true,'usableForTrial'=>false,'canDispatch'=>false,'configHash'=>str_repeat('a',64)]; + function ck($v,$t){global$count;if(!$v)throw new RuntimeException('FAIL '.$t);$count++;echo "PASS $t\n";} + $m=new FeedbackReceiver($response,$op);$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true); + ck($p['status']==='preview_only'&&$p['actualBatteryW']==0,'real receiver uses corrected physical feedback rather than held -9kW'); + ck($p['previewBatteryW']==3000,'net target translated once to total battery demand'); + ck($p['canDispatch']===false&&$p['localFeedback']['b']['estimated']===true,'model feedback remains marked and preview grants no dispatch'); + $feedback=['status'=>'unavailable','batteryW'=>null,'gridW'=>null];$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true); + ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'configured feedback failure never falls back to held virtual display'); + $configured=false;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true); + ck($p['status']==='preview_only'&&$p['feedbackMeaning']==='legacy_display_only_not_for_trial','unconfigured old preview stays compatible but explicitly not for control'); + ck($p['canDispatch']===false,'legacy display cannot gain actuation permission'); + echo "TOTAL $count actual receiver checks passed; synthetic read-only controller API.\n"; +}