feat(control): connect physical feedback to bounded V4 command and fallback paths

This commit is contained in:
ENELIX Agent
2026-10-03 11:40:54 +00:00
parent 8eb9688942
commit 9878197aea
24 changed files with 891 additions and 7 deletions
+18 -2
View File
@@ -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' => [
[
+6
View File
@@ -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');
+108
View File
@@ -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.
@@ -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"
}
}
@@ -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.')
@@ -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
}
]
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/** File-only installation helpers. No module classes loaded from the staging tree. */
function v4FeedbackWrite(string $path, string $content, int $mode=0644): void
{
if (is_link($path)) throw new RuntimeException('Symlink wird nicht ersetzt.');
$temp=tempnam(dirname($path),'.v4-feedback-');
if ($temp===false) throw new RuntimeException('Temporaere Datei nicht verfuegbar.');
try {
if (file_put_contents($temp,$content,LOCK_EX)!==strlen($content) || !chmod($temp,$mode) || !rename($temp,$path)) throw new RuntimeException('Atomarer Dateitausch fehlgeschlagen.');
} finally { if(is_file($temp))unlink($temp); }
}
function v4FeedbackFiles(string $stage,string $target): array
{
$allowed=['libs/NetzfahrplanV4Rueckmeldung.php','libs/BatterieNetzfahrplanV4RueckmeldungTrait.php','libs/NetzfahrplanV4Regeltest.php',
'libs/BatterieNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4TestTrait.php','libs/ManagerNetzfahrplanV4EmpfangTrait.php','libs/BatterieRegler.php','Batterie/module.php','Manager/module.php'];
$m=json_decode((string)file_get_contents($stage.'/MANIFEST.json'),true,32,JSON_THROW_ON_ERROR);
if (($m['scope']??null)!=='corrected_feedback_trial_disabled' || count($m['files']??[])!==count($allowed) || array_diff(array_keys($m['files']),$allowed)) throw new RuntimeException('Unerwarteter Dateiumfang.');
if (realpath($target)!==$target || is_link($target)) throw new RuntimeException('Modulpfad unerwartet.');
foreach($m['dependencies'] as $name=>$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];
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
// Execute only in Lihrenmoos Symcon. Installs code and readonly feedback mapping; never starts a trial.
if(!function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Nur im IP-Symcon-Skripteditor ausfuehren.');
require_once __DIR__.'/file_installer.php';
$manager=17004;$battery=44234;$locked=false;$changedMapping=false;$oldMapping=null;
$report=['scope'=>'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";
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
/** Shared physical reading used by receiver and battery immediately before a trial command. */
trait BatterieNetzfahrplanV4RueckmeldungTrait
{
private function registriereV4Rueckmeldung(): void
{
$this->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);
}
}
+13
View File
@@ -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;
@@ -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)];
+3
View File
@@ -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.');
}
+26
View File
@@ -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);
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
final class NetzfahrplanV4Rueckmeldung
{
private static function number($v, float $lo, float $hi): float
{
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$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);
}
}
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS\Tests;
use PHPUnit\Framework\TestCase;
final class NetzfahrplanV4RueckmeldungTest extends TestCase
{
private function checkScript(string $name): void
{
$pipes=[];
$p=proc_open([PHP_BINARY,__DIR__.'/V4Feedback/'.$name],
[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 ',$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');}
}
+18 -2
View File
@@ -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";
}
+22
View File
@@ -0,0 +1,22 @@
<?php
// Run through actual Manager/Battery trial traits, fresh read helper, and simulated register driver.
[$m,$b,$s]=fixture();$b->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');
+58
View File
@@ -0,0 +1,58 @@
<?php
// Offline only, no actual Symcon calls.
declare(strict_types=1);
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung as F;
$count=0;
function check($b,$s){global $count;if(!$b)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
function fails($f,$s){try{$f();}catch(Throwable $e){check(true,$s);return;}throw new RuntimeException('FAIL no rejection '.$s);}
function fx($mode='physical_sum') {
$now=1791032400;$c=['version'=>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";
+23
View File
@@ -0,0 +1,23 @@
<?php
// Exercise the EXACT serialized fallback wrapper from Manager/module.php in an isolated harness.
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline only');
$source=file_get_contents(__DIR__.'/../../Manager/module.php');
$a=strpos($source,' private function regeln(bool $erneuern): void');$b=strpos($source,' private function regelnIntern(bool $erneuern): void',$a);
if($a===false||$b===false)throw new RuntimeException('Manager wrapper source changed');
$method=substr($source,$a,$b-$a);$calls=[];$locked=false;
function IPS_SemaphoreEnter($k,$t){global$locked;if($locked)return false;$locked=true;return true;}
function IPS_SemaphoreLeave($k){global$locked;$locked=false;}
function IPS_GetInstance($id){return ['InstanceStatus'=>$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";
+23
View File
@@ -0,0 +1,23 @@
<?php
// Exact Battery message builder with no optional visible diagnostic variables.
if(function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Offline only');
class IPSModule {
public int $InstanceID=44234;public array $buffers=['V4FeedbackEstimated'=>'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";
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Belevo\EnelixEMS {
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('CLI test only');
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
class Lizenzpruefung {const NETZFAHRPLAN='synthetic';}
function time(){return $GLOBALS['now'];}
function IPS_InstanceExists($id){return $id===44234;}
function IPS_GetInstance($id){return ['ModuleInfo'=>['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";
}