feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
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:
+60
-145
@@ -17,6 +17,7 @@ require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php';
|
||||
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4TestTrait.php';
|
||||
require_once __DIR__ . '/../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
|
||||
require_once __DIR__ . '/../libs/ManagerPrognoseFormular.php';
|
||||
require_once __DIR__ . '/../libs/ManagerPrognoseWorkflowTrait.php';
|
||||
|
||||
use Belevo\EnelixEMS\Anlagentopologie;
|
||||
use Belevo\EnelixEMS\EinspeiseRegler;
|
||||
@@ -36,6 +37,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4Trait;
|
||||
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4TestTrait;
|
||||
use \Belevo\EnelixEMS\ManagerNetzfahrplanV4AktivtestTrait;
|
||||
use \Belevo\EnelixEMS\ManagerPrognoseWorkflowTrait;
|
||||
|
||||
private const STATUS_AKTIV = 102;
|
||||
private const STATUS_MESSWERT_UNGUELTIG = 201;
|
||||
@@ -44,7 +46,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
private const LIZENZ_ENDPOINT = 'https://license.enelix.ch/api/v1/licenses/activate';
|
||||
private const PROGNOSE_TOPOLOGIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/topology';
|
||||
private const PROGNOSE_TELEMETRIE_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/telemetry';
|
||||
private const PROGNOSE_FAHRPLAN_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/prognosis/schedule';
|
||||
private const STOERUNGS_ENDPOINT = 'https://license.enelix.ch/api/v1/installations/%s/faults';
|
||||
private const LIZENZ_RETRY_SEKUNDEN = 3600;
|
||||
private const STOERUNGS_RETRY_MAX_SEKUNDEN = 900;
|
||||
@@ -130,6 +131,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->registriereNetzfahrplanV4();
|
||||
$this->registriereV4ManagerTest();
|
||||
$this->registriereV4ManagerAktivtest();
|
||||
$this->registrierePrognoseWorkflow();
|
||||
|
||||
$this->RegisterPropertyInteger('Rolle', 0);
|
||||
$this->RegisterPropertyInteger('NetzleistungVariableID', 0);
|
||||
@@ -148,7 +150,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->RegisterPropertyFloat('Umschaltdifferenz', 5.0);
|
||||
$this->RegisterPropertyBoolean('EinspeisebegrenzungAktiv', false);
|
||||
$this->RegisterPropertyFloat('Einspeisegrenze', 0.0);
|
||||
$this->RegisterPropertyBoolean('PrognoseAktiv', false);
|
||||
$this->RegisterPropertyBoolean('PrognoseAktiv', true);
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanAktiv', false);
|
||||
$this->RegisterPropertyString('AnlagenWechselrichter', '[]');
|
||||
$this->RegisterPropertyString('AnlagenPVFlaechen', '[]');
|
||||
@@ -274,7 +276,7 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->stoppeNetzfahrplanV4Vorschau();
|
||||
|
||||
$this->initialisiereLizenzInstallationID();
|
||||
$this->konfiguriereNetzfahrplanV4Daten();
|
||||
$this->vorbereitePrognoseWorkflow();
|
||||
$this->aktualisiereVariablen();
|
||||
foreach (ManagerPrognoseFormular::laufzeitAnzeige() as $ident => $name) {
|
||||
$objektID = $this->GetIDForIdent($ident);
|
||||
@@ -336,11 +338,13 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
return;
|
||||
}
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
$this->konfiguriereNetzfahrplanV4Daten();
|
||||
if ($this->prognoseLernenAktiv()) {
|
||||
$this->SetTimerInterval('NetzfahrplanV4Senden', 90000);
|
||||
}
|
||||
$this->konfiguriereNetzfahrplanV4Vorschau();
|
||||
$this->synchronisiereAnlagentopologie();
|
||||
$this->aktualisierePrognoseWorkflow();
|
||||
if ($this->prognoseTelemetrieKonfiguriert()) {
|
||||
$this->SetTimerInterval(
|
||||
'PrognoseSenden',
|
||||
@@ -539,14 +543,16 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
if (!is_bool($wert)) {
|
||||
throw new InvalidArgumentException('Netzfahrplan-Schaltung muss ein boolescher Wert sein.');
|
||||
}
|
||||
// The saved property is the only normal control request; old test buttons
|
||||
// cannot start a session through this action any longer.
|
||||
if ($wert && !ManagerPrognoseFormular::startErlaubt($this->prognoseBedienzustand())) {
|
||||
throw new RuntimeException('Netzfahrplan nicht freigegeben. Gespeicherte Konfiguration, Lizenz und Testfreigabe pruefen.');
|
||||
}
|
||||
try {
|
||||
$this->V4ManagerAktivtestSchalten($wert);
|
||||
} finally {
|
||||
$this->aktualisierePrognoseBedienung();
|
||||
throw new RuntimeException('Netzfahrplan noch nicht bereit. Status im Prognosebereich pruefen.');
|
||||
}
|
||||
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'value', $wert);
|
||||
return;
|
||||
|
||||
case 'PrognoseWorkflow':
|
||||
$this->aktualisierePrognoseWorkflow();
|
||||
return;
|
||||
|
||||
case 'NetzfahrplanV4DatenErfassen':
|
||||
@@ -581,12 +587,12 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
|
||||
);
|
||||
$this->UpdateFormField(
|
||||
'NetzfahrplanAktiv',
|
||||
'NetzfahrplanRegelungAktiv',
|
||||
'enabled',
|
||||
$aktiv && $netzfahrplanLizenziert && $this->peakShavingLizenziert()
|
||||
$aktiv && $netzfahrplanLizenziert && $this->prognoseWorkflowZustand()['ready']
|
||||
);
|
||||
if (!$aktiv) {
|
||||
$this->UpdateFormField('NetzfahrplanAktiv', 'value', false);
|
||||
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'value', false);
|
||||
}
|
||||
$this->aktualisierePrognoseBedienung($aktiv);
|
||||
return;
|
||||
@@ -896,10 +902,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->setzeDiagnosewert('WirksameLastspitzengrenze', $lastspitzengrenze ?? 0.0);
|
||||
$this->regleEinspeisung($netzleistung, $stoerungen, $erneuern);
|
||||
|
||||
$fahrplanVorgabe = $this->aktuelleNetzfahrplanVorgabe();
|
||||
$zielnetzleistung = $betriebsart === ManagerRegler::BETRIEBSART_PEAK
|
||||
? (float) $lastspitzengrenze
|
||||
: ($fahrplanVorgabe ?? $this->ReadPropertyFloat('SollwertSolarladen'));
|
||||
: $this->ReadPropertyFloat('SollwertSolarladen');
|
||||
$ergebnis = ManagerRegler::verteile(
|
||||
$netzleistung,
|
||||
$zielnetzleistung,
|
||||
@@ -923,20 +928,46 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
);
|
||||
|
||||
$this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);
|
||||
$nativeSollwerte = $ergebnis['Sollwerte'];
|
||||
$v4Report = $this->korrigierePrognoseBatterie($ergebnis, $verbraucher, $synchronisiert);
|
||||
if ($v4Report !== null) {
|
||||
// The correction anticipates other targets. Send those first; a failed
|
||||
// handoff must not be compensated as though the other load had changed.
|
||||
$batteryID = $v4Report['batteryInstanceId'];
|
||||
$corrected = $ergebnis['Sollwerte'][$batteryID];
|
||||
unset($ergebnis['Sollwerte'][$batteryID]);
|
||||
$ergebnis['Sollwerte'][$batteryID] = $corrected;
|
||||
}
|
||||
$v4Sent = false;
|
||||
$sendFailed = false;
|
||||
$letzteSollwerte = $this->leseJsonAttribut('LetzteSollwerte');
|
||||
foreach ($ergebnis['Sollwerte'] as $instanzID => $sollleistung) {
|
||||
if (!$erneuern && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
|
||||
$v4Battery = $v4Report !== null && (int) $instanzID === $v4Report['batteryInstanceId'];
|
||||
if ($v4Battery && $sendFailed) {
|
||||
$sollleistung = $nativeSollwerte[$instanzID];
|
||||
$ergebnis['Sollwerte'][$instanzID] = $sollleistung;
|
||||
$v4Report['fallbackTargetW'] = $sollleistung;
|
||||
}
|
||||
if (!$erneuern && !$v4Battery && ($letzteSollwerte[(string) $instanzID] ?? null) === $sollleistung) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->sendeManagerdaten((int) $instanzID, $betriebsart, $sollleistung);
|
||||
$letzteSollwerte[(string) $instanzID] = $sollleistung;
|
||||
if ($v4Battery && !$sendFailed) $v4Sent = true;
|
||||
} catch (Throwable $fehler) {
|
||||
$sendFailed = true;
|
||||
$this->v4ManagerTestAbbrechen('Andere Verbraucher-Stellwertuebergabe fehlgeschlagen');
|
||||
$stoerungen[] = 'Sollwert an Instanz ' . $instanzID . ': ' . $fehler->getMessage();
|
||||
}
|
||||
}
|
||||
$this->WriteAttributeString('LetzteSollwerte', json_encode($letzteSollwerte, JSON_THROW_ON_ERROR));
|
||||
if ($v4Report !== null) {
|
||||
$this->WriteAttributeString('PrognoseV4Regelstatus', json_encode($v4Report + [
|
||||
'at' => time(), 'controlActive' => $v4Sent && !$sendFailed,
|
||||
'reason' => $sendFailed ? 'Stellwertuebergabe fehlgeschlagen.' : 'Batterievorgabe an den lokalen Regler uebergeben.',
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
$this->aktualisiereStoerungen($verbraucher, $stoerungen);
|
||||
$this->SetStatus(self::STATUS_AKTIV);
|
||||
@@ -1567,7 +1598,9 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
'Sollleistung_W' => $sollleistung,
|
||||
];
|
||||
Nachrichtenvertrag::pruefeManagerdaten($paket);
|
||||
IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR));
|
||||
if (IPS_RequestAction($instanzID, 'ManagerdatenEmpfangen', json_encode($paket, JSON_THROW_ON_ERROR)) === false) {
|
||||
throw new RuntimeException('Verbraucher hat die Managerdaten nicht angenommen.');
|
||||
}
|
||||
$this->protokolliere('Managerdaten', [
|
||||
'InstanzID' => $instanzID,
|
||||
'Betriebsart' => $betriebsart,
|
||||
@@ -2595,19 +2628,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|
||||
&& !$this->prognoseBerechtigungInLease($lease)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
|
||||
&& Lizenzpruefung::berechtigungsMenge(
|
||||
$lease,
|
||||
Lizenzpruefung::NETZFAHRPLAN
|
||||
) < 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Forecast entitlement gates only the forecast workflow. A missing or
|
||||
// expired forecast option must not disable licensed local protection.
|
||||
|
||||
try {
|
||||
[$mengen] = $this->verbraucherLizenzbelegung(
|
||||
@@ -2688,17 +2710,17 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
$this->prognoseLizenzStatustext($aktiv, $lizenziert)
|
||||
);
|
||||
$this->UpdateFormField(
|
||||
'NetzfahrplanAktiv',
|
||||
'NetzfahrplanRegelungAktiv',
|
||||
'enabled',
|
||||
$aktiv && $netzfahrplanLizenziert
|
||||
&& $this->peakShavingLizenziert($lizenzcode)
|
||||
$this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|
||||
|| ($aktiv && $netzfahrplanLizenziert && $this->prognoseWorkflowZustand()['ready'])
|
||||
);
|
||||
$this->aktualisierePrognoseBedienung(null, $lizenzcode);
|
||||
}
|
||||
|
||||
private function prognoseBedienzustand(?bool $aktiv = null, ?string $lizenzcode = null): array
|
||||
{
|
||||
return [
|
||||
return $this->prognoseWorkflowZustand() + [
|
||||
'prognoseAktiv' => ($aktiv ?? true) && $this->ReadPropertyBoolean('PrognoseAktiv'),
|
||||
'lizenziert' => $this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN, $lizenzcode),
|
||||
'senderAktiv' => $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv'),
|
||||
@@ -2719,9 +2741,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
private function aktualisierePrognoseBedienung(?bool $aktiv = null, ?string $lizenzcode = null): void
|
||||
{
|
||||
$zustand = $this->prognoseBedienzustand($aktiv, $lizenzcode);
|
||||
$this->UpdateFormField('PrognoseNetzfahrplanStart', 'enabled', ManagerPrognoseFormular::startErlaubt($zustand));
|
||||
$this->UpdateFormField('NetzfahrplanRegelungAktiv', 'enabled', $zustand['controlRequested'] || ManagerPrognoseFormular::startErlaubt($zustand));
|
||||
$this->UpdateFormField('PrognoseNetzfahrplanStatus', 'caption', ManagerPrognoseFormular::statustext($zustand));
|
||||
$this->UpdateFormField('NetzfahrplanV4EmpfangAktiv', 'enabled', $zustand['lizenziert'] || $zustand['empfangAktiv']);
|
||||
}
|
||||
|
||||
private function prognoseLizenzStatustext(bool $aktiv, bool $lizenziert): string
|
||||
@@ -2926,22 +2947,8 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
if ($this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')) {
|
||||
$this->regelbareWechselrichter();
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('PrognoseAktiv')
|
||||
&& !$this->prognoseTelemetrieKonfiguriert()
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
'Aktive Prognosen benoetigen Netzleistung, PV-Leistung und Batterie-Ladezustand.'
|
||||
);
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')
|
||||
&& (!$this->ReadPropertyBoolean('PrognoseAktiv')
|
||||
|| !$this->ReadPropertyBoolean('EinspeisebegrenzungAktiv')
|
||||
|| !$this->prognoseTelemetrieKonfiguriert())
|
||||
) {
|
||||
throw new InvalidArgumentException(
|
||||
'Der intelligente Netzfahrplan benoetigt Einspeisebegrenzung und Prognosetelemetrie.'
|
||||
);
|
||||
}
|
||||
// Forecast onboarding errors are reported separately and never disable
|
||||
// the existing local manager or turn on a legacy schedule.
|
||||
$this->leseAktiveVerbraucherIDs('VerbraucherZuordnung');
|
||||
$this->leseAktiveVerbraucherIDs('AutomatischeVerbraucherZuordnung');
|
||||
$this->dekodiereMonatsgrenzen($this->ReadPropertyString('Monatsgrenzen'));
|
||||
@@ -2990,34 +2997,11 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
|
||||
private function prognoseTelemetrieKonfiguriert(): bool
|
||||
{
|
||||
return $this->ReadPropertyBoolean('PrognoseAktiv')
|
||||
return $this->prognoseLernenAktiv()
|
||||
&& $this->messquellenKonfiguriert()
|
||||
&& $this->aktuelleBatterieSoc() !== null;
|
||||
}
|
||||
|
||||
private function aktuelleNetzfahrplanVorgabe(): ?float
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
return null;
|
||||
}
|
||||
$fahrplan = $this->leseJsonAttribut('Netzfahrplan');
|
||||
if (($fahrplan['status'] ?? null) !== 'available'
|
||||
|| !is_array($fahrplan['points'] ?? null)
|
||||
|| !is_string($fahrplan['validUntil'] ?? null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$gueltigBis = strtotime($fahrplan['validUntil']);
|
||||
if ($gueltigBis === false || $gueltigBis <= time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagerRegler::interpolierteFahrplanVorgabe(
|
||||
$fahrplan['points'],
|
||||
time()
|
||||
);
|
||||
}
|
||||
|
||||
private function synchronisiereAnlagentopologie(): void
|
||||
{
|
||||
try {
|
||||
@@ -3157,9 +3141,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
'values' => $werte,
|
||||
];
|
||||
$this->uebertragePrognoseTelemetrie($installationID, $nutzlast, $token);
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
$this->aktualisiereNetzfahrplan($installationID, $token);
|
||||
}
|
||||
$this->setzeDiagnosewert('Prognosestatus', 'Verbunden');
|
||||
} catch (Throwable $fehler) {
|
||||
$meldung = substr($fehler->getMessage(), 0, 500);
|
||||
@@ -3168,72 +3149,6 @@ class Manager extends IPSModule implements ManagerSchnittstelle
|
||||
}
|
||||
}
|
||||
|
||||
private function aktualisiereNetzfahrplan(string $installationID, string $token): void
|
||||
{
|
||||
$url = sprintf(
|
||||
self::PROGNOSE_FAHRPLAN_ENDPOINT,
|
||||
rawurlencode($installationID)
|
||||
) . '?' . http_build_query([
|
||||
'exportLimitW' => $this->ReadPropertyFloat('Einspeisegrenze'),
|
||||
]);
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Der Netzfahrplan konnte nicht vorbereitet werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $token,
|
||||
],
|
||||
CURLOPT_USERAGENT => 'Enelix-EMS-Manager/0.2',
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
if ($antwort === false) {
|
||||
throw new RuntimeException('Netzfahrplan nicht erreichbar: ' . curl_error($curl));
|
||||
}
|
||||
if (strlen($antwort) > 524288) {
|
||||
throw new RuntimeException('Die Antwort des Netzfahrplans ist zu gross.');
|
||||
}
|
||||
$httpStatus = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
} finally {
|
||||
curl_close($curl);
|
||||
}
|
||||
|
||||
if ($httpStatus === 401) {
|
||||
$this->WriteAttributeString('PrognoseInstallationsToken', '');
|
||||
}
|
||||
if ($httpStatus !== 200) {
|
||||
$meldung = 'Unbekannter Fehler';
|
||||
try {
|
||||
$fehlerdaten = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (is_array($fehlerdaten) && is_string($fehlerdaten['error'] ?? null)) {
|
||||
$meldung = $fehlerdaten['error'];
|
||||
}
|
||||
} catch (Throwable $fehler) {
|
||||
// Der HTTP-Status bleibt die fuehrende Fehlerinformation.
|
||||
}
|
||||
throw new RuntimeException(
|
||||
'Netzfahrplan HTTP ' . $httpStatus . ': ' . $meldung
|
||||
);
|
||||
}
|
||||
|
||||
$fahrplan = json_decode($antwort, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($fahrplan)
|
||||
|| ($fahrplan['schemaVersion'] ?? null) !== 1
|
||||
|| !is_array($fahrplan['points'] ?? null)
|
||||
) {
|
||||
throw new RuntimeException('Der Netzfahrplan hat ein ungueltiges Format.');
|
||||
}
|
||||
$this->WriteAttributeString(
|
||||
'Netzfahrplan',
|
||||
json_encode($fahrplan, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $nutzlast */
|
||||
private function uebertragePrognoseTelemetrie(
|
||||
|
||||
Reference in New Issue
Block a user