feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s

Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+294
View File
@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use RuntimeException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Einrichtung.php';
require_once __DIR__ . '/NetzfahrplanV4Regelung.php';
require_once __DIR__ . '/NetzfahrplanV4Geraeteabruf.php';
require_once __DIR__ . '/NetzfahrplanV4RueckmeldungEinrichtung.php';
/** One learning consent and one local control request. No server-side actuator authority. */
trait ManagerPrognoseWorkflowTrait
{
private function registrierePrognoseWorkflow(): void
{
$this->RegisterPropertyBoolean('NetzfahrplanRegelungAktiv', false);
$this->RegisterAttributeString('PrognoseV4Einrichtung', '{}');
$this->RegisterAttributeString('PrognoseV4Serverstatus', '{}');
$this->RegisterAttributeString('PrognoseV4Regelstatus', '{}');
$this->RegisterAttributeString('PrognoseV4Einrichtungsfehler', '');
$this->RegisterAttributeString('PrognoseV4Verbindungsfehler', '');
$this->RegisterAttributeInteger('PrognoseV4EingerichtetAm', 0);
$this->RegisterTimer('PrognoseWorkflow', 0,
"IPS_RequestAction(\$_IPS['TARGET'], 'PrognoseWorkflow', true);");
}
private function prognoseLernenAktiv(): bool
{
return $this->ReadPropertyBoolean('PrognoseAktiv')
&& $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN);
}
private function prognoseV4Datensatz(): string
{
$custom = $this->ReadPropertyString('NetzfahrplanV4Datensatz');
if ($custom !== '') return $custom;
return $this->leseJsonAttribut('PrognoseV4Einrichtung')['datasetId'] ?? '';
}
private function prognoseV4Messkonfiguration(): string
{
$custom = $this->ReadPropertyString('NetzfahrplanV4Messkonfiguration');
if (!in_array(trim($custom), ['', '{}'], true)) return $custom;
return json_encode($this->leseJsonAttribut('PrognoseV4Einrichtung')['capture'] ?? new \stdClass(),
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
}
/** Passive configuration lookup for the battery sampler; no device reads or writes. */
public function GetV4RueckmeldungKonfiguration(int $batteryID): string
{
try {
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== ''
|| !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true)) return '{}';
$setup = $this->leseJsonAttribut('PrognoseV4Einrichtung');
$capture = $setup['capture'] ?? [];
foreach (($capture['accounting']['sources'] ?? []) as $source) {
if (!IPS_VariableExists($source['variableId'])) return '{}';
$o = IPS_GetObject($source['variableId']);
if ($o['ParentID'] !== $source['parentId'] || $o['ObjectIdent'] !== $source['ident']
|| !IPS_InstanceExists($o['ParentID'])) return '{}';
$module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID'];
if (!in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE,
NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true)) return '{}';
if ($source['role'] === 'grid' && ($source['variableId'] !== $this->ReadPropertyInteger('NetzleistungVariableID')
|| $source['factorToW'] != $this->ReadPropertyFloat('Netzleistungsfaktor'))) return '{}';
}
$controllers = [];
foreach ($this->aktiveVerbraucherIDs() as $id) {
if (!IPS_InstanceExists($id)
|| IPS_GetInstance($id)['ModuleInfo']['ModuleID'] !== '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}') continue;
$controller = ['InstanzID' => $id];
foreach (['IstleistungVariableID', 'LadezustandVariableID', 'NetzleistungVariableID', 'MesswertMaxAlter'] as $key) {
$controller[$key] = IPS_GetProperty($id, $key);
}
$controllers[] = $controller;
}
$config = NetzfahrplanV4RueckmeldungEinrichtung::erstellen($capture,
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $batteryID,
json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $controllers);
return json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
} catch (Throwable) {
return '{}';
}
}
private function vorbereitePrognoseWorkflow(): void
{
$this->WriteAttributeString('PrognoseV4Regelstatus', '{}');
$this->WriteAttributeString('PrognoseV4Serverstatus', '{}');
$this->WriteAttributeInteger('PrognoseV4EingerichtetAm', 0);
$this->WriteAttributeString('PrognoseV4Einrichtungsfehler', '');
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', '');
$this->SetTimerInterval('PrognoseWorkflow', 30000);
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return;
try {
$custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true);
if ($custom) {
// Specialist mappings and their server-side dataset are already commissioned.
if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') === '') {
throw new RuntimeException('Individuelle Messzuordnung benoetigt einen eigenen Datensatz.');
}
$this->v4DatenKonfiguration();
return;
}
if ($this->ReadPropertyString('NetzfahrplanV4Datensatz') !== '') {
throw new RuntimeException('Bestehender Datensatz ohne Messzuordnung; Einrichtung vervollstaendigen.');
}
$manager = [
'NetzleistungVariableID' => $this->ReadPropertyInteger('NetzleistungVariableID'),
'Netzleistungsfaktor' => $this->ReadPropertyFloat('Netzleistungsfaktor'),
'MesswertMaxAlter' => $this->ReadPropertyInteger('MesswertMaxAlter'),
'SDLAktiv' => $this->ReadPropertyBoolean('SDLAktiv'),
'SDLLeistungVariableID' => $this->ReadPropertyInteger('SDLLeistungVariableID'),
];
$reader = function (int $id): array {
if (!IPS_VariableExists($id)) throw new RuntimeException('Messquelle fehlt.');
$o = IPS_GetObject($id);$v = IPS_GetVariable($id);
if (($o['ObjectType'] ?? null) !== 2 || !IPS_InstanceExists($o['ParentID'])) {
throw new RuntimeException('Direkte Geraetemessung erforderlich.');
}
$module = IPS_GetInstance($o['ParentID'])['ModuleInfo']['ModuleID'];
// A different library alone does not prove physical metering: gateways
// can publish virtual allocations from a completely separate repository.
$physical = in_array($module, [NetzfahrplanV4Geraeteabruf::MBUS_DEVICE,
NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true);
return ['parentID' => $o['ParentID'], 'ident' => $o['ObjectIdent'],
'type' => $v['VariableType'], 'virtual' => !$physical];
};
$configuration = NetzfahrplanV4Einrichtung::erstellen($this->ReadAttributeString('LizenzInstallationID'),
$this->InstanceID, $manager,
json_decode($this->ReadPropertyString('AnlagenWechselrichter'), true, 64, JSON_THROW_ON_ERROR),
json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR), $reader);
$previous = $this->leseJsonAttribut('PrognoseV4Einrichtung');
if ($previous !== [] && ($previous['mappingSha256'] ?? '') !== $configuration['mappingSha256']) {
throw new RuntimeException('Messzuordnung geaendert; bestehende Lerndaten bleiben erhalten. Datensatzmigration erforderlich.');
}
$this->WriteAttributeString('PrognoseV4Einrichtung', json_encode($configuration,
JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Einrichtungsfehler', substr($e->getMessage(), 0, 300));
}
}
/** Network traffic belongs to this slow timer, never to the actuator loop. */
private function aktualisierePrognoseWorkflow(): void
{
$lock = 'ENELIX.V4.Workflow.' . $this->InstanceID;
if (!IPS_SemaphoreEnter($lock, 0)) return;
try {
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) return;
$setup = $this->leseJsonAttribut('PrognoseV4Einrichtung');
$custom = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration')), ['', '{}'], true);
if (!$custom && $this->prognoseLernenAktiv() && $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') === '') {
if (isset($setup['dataset']) && time() - $this->ReadAttributeInteger('PrognoseV4EingerichtetAm') > 300) {
$answer = $this->prognoseWorkflowHttp('/setup', ['version' => 1,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'),
'dataset' => $setup['dataset'], 'learningEnabled' => true]);
if (($answer['datasetId'] ?? null) !== $setup['datasetId']
|| ($answer['selectedDatasetId'] ?? null) !== $setup['datasetId']) {
throw new RuntimeException('Server verwendet einen anderen Datensatz; vorhandene Auswahl bleibt erhalten.');
}
$this->WriteAttributeInteger('PrognoseV4EingerichtetAm', time());
}
}
$s = $this->prognoseWorkflowZustand();
$this->prognoseWorkflowHttp('/workflow', ['version' => 1,
'installationId' => $this->ReadAttributeString('LizenzInstallationID'), 'observedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'learningEnabled' => $this->prognoseLernenAktiv(),
'controlRequested' => $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv'),
'controlActive' => $s['controlActive'], 'reason' => substr($s['reason'], 0, 300)]);
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', '');
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Verbindungsfehler', substr($e->getMessage(), 0, 300));
} finally {
IPS_SemaphoreLeave($lock);
$this->aktualisierePrognoseBedienung();
}
}
private function prognoseWorkflowHttp(string $suffix, array $body): array
{
if (!in_array($suffix, ['/setup', '/workflow'], true)) throw new RuntimeException('Unzulaessige Workflowaktion.');
$id = $this->ReadAttributeString('LizenzInstallationID');
$token = $this->ReadAttributeString('PrognoseInstallationsToken');
if (!preg_match('/^[0-9a-f-]{36}$/D', $id) || !preg_match('/^[A-Za-z0-9_-]{43,128}$/D', $token)) {
throw new RuntimeException('Lizenz mit dieser Installation verbinden.');
}
$h = curl_init('https://license.enelix.ch/api/v1/installations/' . rawurlencode($id) . '/prognosis/planner-v4' . $suffix);
if ($h === false) throw new RuntimeException('Prognoseverbindung nicht verfuegbar.');
$response = '';
try {
curl_setopt_array($h, [CURLOPT_POST => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION),
CURLOPT_WRITEFUNCTION => static function ($curl, string $chunk) use (&$response): int {
if (strlen($response) + strlen($chunk) > 65536) return 0;
$response .= $chunk;return strlen($chunk);
}]);
$ok = curl_exec($h);$status = (int) curl_getinfo($h, CURLINFO_HTTP_CODE);
if ($ok === false || $status !== 200) throw new RuntimeException('Prognoseeinrichtung HTTP ' . $status . '.');
$answer = json_decode($response, true, 32, JSON_THROW_ON_ERROR);
if (!is_array($answer)) throw new RuntimeException('Ungueltige Workflowantwort.');
return $answer;
} finally { curl_close($h); }
}
private function prognoseWorkflowZustand(): array
{
$requested = $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv');
$base = ['state' => 'disabled', 'reason' => 'Datenerfassung ausgeschaltet.', 'ready' => false,
'controlRequested' => $requested, 'controlActive' => false];
if (!$this->ReadPropertyBoolean('PrognoseAktiv')) return $base;
$base['state'] = 'collecting';$base['reason'] = 'Messdaten werden gesammelt.';
if (!$this->prognoseLernenAktiv()) {
return array_replace($base, ['state' => 'interrupted', 'reason' => 'Netzfahrplanlizenz mit der Installation verbinden.']);
}
$error = $this->ReadAttributeString('PrognoseV4Einrichtungsfehler');
if ($error === '') $error = $this->ReadAttributeString('PrognoseV4Verbindungsfehler');
if ($error !== '') return array_replace($base, ['state' => 'interrupted', 'reason' => $error]);
$server = $this->leseJsonAttribut('PrognoseV4Serverstatus');
$at = $server['receivedAt'] ?? 0;
if (!is_int($at) || $at > time() || time() - $at > 90) {
return array_replace($base, ['state' => 'interrupted', 'reason' => 'Aktuellen Serverstatus abwarten.']);
}
$workflow = $server['workflow'] ?? [];
if (($workflow['planningReady'] ?? false) !== true) {
return array_replace($base, ['state' => $requested ? 'interrupted' : (($workflow['state'] ?? '') === 'learning' ? 'learning' : 'collecting'),
'reason' => (string) ($workflow['reason'] ?? 'Modell und gueltiger Plan werden vorbereitet.')]);
}
try {
$this->prognoseV4Regelbereitschaft();
$base = array_replace($base, ['ready' => true, 'state' => 'ready', 'reason' => 'Bereit. Netzfahrplan kann eingeschaltet werden.']);
} catch (Throwable $e) {
return array_replace($base, ['state' => 'interrupted', 'reason' => substr($e->getMessage(), 0, 300)]);
}
$local = $this->leseJsonAttribut('PrognoseV4Regelstatus');
$active = $requested && ($local['controlActive'] ?? false) === true
&& ($local['at'] ?? 0) <= time() && time() - ($local['at'] ?? 0) <= 10;
if ($requested) {
$base['state'] = $active ? 'active' : 'interrupted';
$base['reason'] = $active ? 'Manager korrigiert die Batterie auf das V4-Netzziel.' : ($local['reason'] ?? 'Naechsten gueltigen Regelzyklus abwarten.');
$base['controlActive'] = $active;
}
return $base;
}
private function prognoseV4Regelbereitschaft(): array
{
if (!$this->prognoseLernenAktiv() || !$this->GetValue('Aktiv')
|| IPS_GetInstance($this->InstanceID)['InstanceStatus'] !== 102) throw new RuntimeException('Manager nicht regelbereit.');
if ($this->v4ManagerTestSession() !== [] || $this->GetValue('NetzfahrplanV4Aktivtest')) {
throw new RuntimeException('Separater Inbetriebnahmetest aktiv; keine zweite Batteriesteuerung.');
}
[$operation, $actual] = $this->netzfahrplanV4LokaleVorschauwerte();
if (count($operation['batteries']) !== 1) throw new RuntimeException('V4 regelt derzeit genau eine Batterie.');
$asset = $operation['batteries'][0]['id'];
$feedback = $operation['localFeedback'][$asset] ?? [];
$battery = $feedback['batteryInstanceId'] ?? 0;
if (!is_int($battery) || !IPS_InstanceExists($battery)
|| !in_array($battery, $this->aktiveVerbraucherIDs(), true)) throw new RuntimeException('Bestaetigte Batterierueckmeldung fehlt.');
$binding = ['batteryInstanceId' => $battery, 'assetId' => $asset,
'watchdogEvidence' => IPS_GetProperty($battery, 'NetzfahrplanV4GeraeteWatchdogNachweis')];
NetzfahrplanV4Regelung::pruefen($this->leseJsonAttribut('NetzfahrplanV4Empfang'),
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual, $binding, time());
return [$operation, $actual, $binding];
}
/** Changes only a bound battery allocation. The ordinary battery module keeps device ownership. */
private function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $synchronized): ?array
{
$this->WriteAttributeString('PrognoseV4Regelstatus', '{}');
if (!$this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')) return null;
try {
$s = $this->prognoseWorkflowZustand();
if (!$s['ready']) throw new RuntimeException($s['reason']);
[$operation, $actual, $binding] = $this->prognoseV4Regelbereitschaft();
$result = NetzfahrplanV4Regelung::korrigieren($this->leseJsonAttribut('NetzfahrplanV4Empfang'),
$this->ReadAttributeString('LizenzInstallationID'), $this->InstanceID, $operation, $actual,
$allocation, $consumers, $synchronized, $binding, time());
$allocation = $result['allocation'];
return $result['report'];
} catch (Throwable $e) {
$this->WriteAttributeString('PrognoseV4Regelstatus', json_encode(['at' => time(), 'controlActive' => false,
'reason' => substr($e->getMessage(), 0, 300)], JSON_THROW_ON_ERROR));
return null;
}
}
}