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:
@@ -9,6 +9,28 @@ require_once __DIR__.'/NetzfahrplanV4Geraeteabruf.php';
|
||||
/** Shared confirmed physical reading used by receiver and battery before a trial command. */
|
||||
trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
{
|
||||
/** Existing specialist mappings always win; automatic lookup is passive. */
|
||||
private function effektiveV4RueckmeldungKonfiguration(?string $property = null): string
|
||||
{
|
||||
$json = $property ?? $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (!in_array(trim($json), ['', '{}'], true)) return $json;
|
||||
$managers = $this->zugeordneteManagerIDs();
|
||||
if (count($managers) !== 1
|
||||
|| (!function_exists('ENELIX_GetV4RueckmeldungKonfiguration')
|
||||
&& !function_exists(__NAMESPACE__ . '\\ENELIX_GetV4RueckmeldungKonfiguration'))) return '';
|
||||
$json = ENELIX_GetV4RueckmeldungKonfiguration($managers[0], $this->InstanceID);
|
||||
if (!is_string($json) || strlen($json) > 65536) {
|
||||
throw new \RuntimeException('feedback_auto_configuration_invalid');
|
||||
}
|
||||
if (in_array(trim($json), ['', '{}'], true)) return '';
|
||||
$config = NetzfahrplanV4Rueckmeldung::configuration(json_decode($json, true, 32, JSON_THROW_ON_ERROR));
|
||||
if ($config['managerId'] !== $managers[0] || $config['batteryInstanceId'] !== $this->InstanceID
|
||||
|| $config['mode'] !== 'physical_sum' || $config['allowEstimatedForTrial']) {
|
||||
throw new \RuntimeException('feedback_auto_configuration_binding_invalid');
|
||||
}
|
||||
return json_encode($config, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
|
||||
}
|
||||
|
||||
private function registriereV4Rueckmeldung(): void
|
||||
{
|
||||
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
|
||||
@@ -21,28 +43,41 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
|
||||
$configured = !in_array(trim($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration')), ['', '{}'], true);
|
||||
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', $configured ? 1000 : 0);
|
||||
$this->SetBuffer('V4ConfirmedFeedbackContext', '');
|
||||
// Assignment can happen after battery ApplyChanges. Empty automatic
|
||||
// configuration returns before device I/O; the existing timer discovers it.
|
||||
$this->SetTimerInterval('NetzfahrplanV4RueckmeldungAktualisieren', 1000);
|
||||
}
|
||||
|
||||
/** No transport I/O under the battery control lock. Original sample times never move. */
|
||||
/** Strictly local: never call the Manager from a Manager -> battery cache read. */
|
||||
private function v4BatterieRueckmeldung(): array
|
||||
{
|
||||
$json = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
$property = $this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
$automatic = in_array(trim($property), ['', '{}'], true);
|
||||
$context = json_decode($this->GetBuffer('V4ConfirmedFeedbackContext'), true);
|
||||
$json = is_array($context) && is_string($context['configuration'] ?? null) ? $context['configuration'] : '';
|
||||
if ($json === '') throw new \RuntimeException('feedback_configuration_unavailable');
|
||||
$sample = json_decode($this->GetBuffer('V4ConfirmedFeedback'), true);
|
||||
$mono = (float) $this->GetBuffer('V4ConfirmedFeedbackMono');
|
||||
$now = time();$age = hrtime(true) / 1e9 - $mono;
|
||||
if (!is_array($sample) || !is_int($sample['checkedAt'] ?? null)
|
||||
|| $sample['checkedAt'] > $now || $now - $sample['checkedAt'] > 2
|
||||
|| $age < 0 || $age > 2
|
||||
|| ($context['propertyHash'] ?? null) !== hash('sha256', $property)
|
||||
|| ($context['autoMode'] ?? null) !== $automatic
|
||||
|| (!$automatic && $json !== $property)
|
||||
|| $this->GetBuffer('V4ConfirmedFeedbackConfig') !== hash('sha256', $json)) {
|
||||
throw new \RuntimeException('feedback_confirmed_cache_stale_or_invalid');
|
||||
}
|
||||
$config = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
|
||||
if (($config['batteryInstanceId'] ?? null) !== $this->InstanceID
|
||||
|| !in_array($config['managerId'] ?? 0, $this->zugeordneteManagerIDs(), true)) {
|
||||
$config = NetzfahrplanV4Rueckmeldung::configuration(json_decode($json, true, 32, JSON_THROW_ON_ERROR));
|
||||
if ($config['batteryInstanceId'] !== $this->InstanceID
|
||||
|| ($automatic && ($config['mode'] !== 'physical_sum' || $config['allowEstimatedForTrial']))
|
||||
|| ($sample['configHash'] ?? null) !== NetzfahrplanV4Rueckmeldung::fingerprint($config)) {
|
||||
throw new \RuntimeException('feedback_cache_binding_invalid');
|
||||
}
|
||||
foreach (['installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
|
||||
if (($sample[$key] ?? null) !== $config[$key]) throw new \RuntimeException('feedback_cache_binding_invalid');
|
||||
}
|
||||
foreach ($config['sources'] as $source) {
|
||||
if (!IPS_VariableExists($source['variableId'])) throw new \RuntimeException('feedback_variable_missing');
|
||||
$object = IPS_GetObject($source['variableId']);
|
||||
@@ -56,11 +91,25 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
/** Independent acquisition; never waits behind another read or takes the actuator lock. */
|
||||
private function aktualisiereV4Rueckmeldung(): void
|
||||
{
|
||||
$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');
|
||||
$property=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
$automatic=in_array(trim($property), ['', '{}'], true);
|
||||
try {
|
||||
$json=$this->effektiveV4RueckmeldungKonfiguration($property);
|
||||
if ($json==='') {
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackConfig', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackMono', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackContext', '');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
} catch (\Throwable $error) {
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$lock='ENELIX.V4.Feedback.'.$this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock,0)) return;
|
||||
@@ -102,9 +151,21 @@ trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
}
|
||||
}
|
||||
if (!is_array($result)) throw new \RuntimeException('feedback_snapshot_unavailable');
|
||||
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
|
||||
$managers=$this->zugeordneteManagerIDs();
|
||||
if ($this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration') !== $property
|
||||
|| !in_array($c['managerId'], $managers, true)
|
||||
|| ($automatic && count($managers) !== 1)) {
|
||||
throw new \RuntimeException('feedback_configuration_changed_during_read');
|
||||
}
|
||||
// Publish the sample last so a concurrent cache read cannot mix contexts.
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
$this->SetBuffer('V4ConfirmedFeedbackContext', json_encode([
|
||||
'configuration' => $json, 'propertyHash' => hash('sha256', $property),
|
||||
'autoMode' => $automatic,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
$this->SetBuffer('V4ConfirmedFeedbackConfig', hash('sha256', $json));
|
||||
$this->SetBuffer('V4ConfirmedFeedbackMono', (string) (hrtime(true) / 1e9));
|
||||
$this->SetBuffer('V4ConfirmedFeedback',json_encode($result,JSON_THROW_ON_ERROR));
|
||||
} catch (\Throwable $error) {
|
||||
$this->SetBuffer('V4ConfirmedFeedback', '');
|
||||
throw $error;
|
||||
|
||||
@@ -29,16 +29,18 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
private function konfiguriereNetzfahrplanV4Daten(): void
|
||||
{
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 0);
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
|
||||
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== '') return;
|
||||
try {
|
||||
$this->v4DatenKonfiguration();
|
||||
$config = $this->v4DatenKonfiguration();
|
||||
if ($this->ReadAttributeInteger('NetzfahrplanV4DatenVersion')<1) {
|
||||
// One retry after the data-only upgrade, never a cursor reset or a device write.
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersuch', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenFehlerzahl', 0);
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4DatenVersion', 1);
|
||||
}
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', 30000);
|
||||
$ages = array_column($config['accounting']['sources'], 'maxAgeSeconds');
|
||||
$seconds = max(1, min(30, (int) floor(min($ages) / 2)));
|
||||
$this->SetTimerInterval('NetzfahrplanV4DatenErfassen', $seconds * 1000);
|
||||
} catch (\Throwable $e) {
|
||||
$this->v4DatenStatus(['status'=>'configuration_error','errorType'=>get_class($e)]);
|
||||
}
|
||||
@@ -47,10 +49,10 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
private function v4DatenKonfiguration(): array
|
||||
{
|
||||
$id=$this->ReadAttributeString('LizenzInstallationID');
|
||||
$dataset=$this->ReadPropertyString('NetzfahrplanV4Datensatz');
|
||||
$dataset=$this->prognoseV4Datensatz();
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$dataset)
|
||||
|| !preg_match('/^[0-9a-f-]{36}$/D',$id)) throw new \RuntimeException('Dataset/installation not configured');
|
||||
$c=json_decode($this->ReadPropertyString('NetzfahrplanV4Messkonfiguration'),true,64,JSON_THROW_ON_ERROR);
|
||||
$c=json_decode($this->prognoseV4Messkonfiguration(),true,64,JSON_THROW_ON_ERROR);
|
||||
$c=NetzfahrplanV4Messaufnahme::configuration($c);
|
||||
if ($c['installationId']!==$id || ($c['managerId']??null)!==$this->InstanceID) throw new \RuntimeException('Measurement mapping belongs to another manager');
|
||||
return $c;
|
||||
@@ -74,12 +76,12 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
$receipt=json_decode($this->ReadAttributeString('NetzfahrplanV4LetzteDatenquittung'),true,32,JSON_THROW_ON_ERROR);
|
||||
$data['lastAcknowledgement']=$receipt;
|
||||
$this->SetValue('NetzfahrplanV4Datenstatus',json_encode($data+['checkedAt'=>gmdate('c'),
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
|
||||
'datasetId'=>$this->prognoseV4Datensatz(),'controlEnabled'=>false],JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function ErfasseNetzfahrplanV4Daten(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4MessdatenAktiv')) return;
|
||||
if (!$this->prognoseLernenAktiv() || $this->ReadAttributeString('PrognoseV4Einrichtungsfehler') !== '') return;
|
||||
$lock='ENELIX.V4.ApplicationData.'.$this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock,0)) return;
|
||||
try {
|
||||
@@ -185,7 +187,7 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
if ($records===[]) return ['status'=>'up_to_date'];
|
||||
$id=$this->ReadAttributeString('LizenzInstallationID');$token=$this->ReadAttributeString('PrognoseInstallationsToken');
|
||||
if ($token==='') throw new \RuntimeException('Existing device token unavailable');
|
||||
$payload=json_encode(['version'=>1,'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),'records'=>$records],JSON_THROW_ON_ERROR);
|
||||
$payload=json_encode(['version'=>1,'datasetId'=>$this->prognoseV4Datensatz(),'records'=>$records],JSON_THROW_ON_ERROR);
|
||||
$h=curl_init('https://license.enelix.ch/api/v1/installations/'.rawurlencode($id).'/prognosis/planner-v4/measurements');
|
||||
if ($h===false) throw new \RuntimeException('Cannot prepare data transfer');
|
||||
try {
|
||||
@@ -197,11 +199,11 @@ trait ManagerNetzfahrplanV4DatenTrait
|
||||
if ($answer===false||$code!==200||strlen($answer)>65536) throw new \RuntimeException('Measurement delivery not acknowledged',$code);
|
||||
$ack=json_decode($answer,true,32,JSON_THROW_ON_ERROR);
|
||||
if (!in_array($ack['status']??null,['stored','duplicate'],true)
|
||||
||($ack['datasetId']??null)!==$this->ReadPropertyString('NetzfahrplanV4Datensatz')
|
||||
||($ack['datasetId']??null)!==$this->prognoseV4Datensatz()
|
||||
||($ack['acceptedThrough']??null)!==$lastTime) throw new \RuntimeException('Measurement acknowledgement does not match batch');
|
||||
$this->WriteAttributeString('NetzfahrplanV4DatenCursor',json_encode($candidate,JSON_THROW_ON_ERROR));
|
||||
return ['status'=>'acknowledged','records'=>count($records),'acceptedThrough'=>$lastTime,
|
||||
'datasetId'=>$this->ReadPropertyString('NetzfahrplanV4Datensatz'),
|
||||
'datasetId'=>$this->prognoseV4Datensatz(),
|
||||
'journalDuplicateTimestampsSkipped'=>$skippedDuplicateTimestamps,
|
||||
'journalAcknowledgedTimestampsSkipped'=>$skippedAcknowledgedTimestamps];
|
||||
} finally { curl_close($h); }
|
||||
|
||||
@@ -41,9 +41,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
|
||||
private function konfiguriereNetzfahrplanV4Vorschau(): void
|
||||
{
|
||||
$enabled = $this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')
|
||||
&& $this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')
|
||||
&& !$this->ReadPropertyBoolean('NetzfahrplanAktiv');
|
||||
$enabled = $this->prognoseLernenAktiv();
|
||||
$this->SetTimerInterval('NetzfahrplanV4VorschauAktualisieren', $enabled ? 10000 : 0);
|
||||
$this->SetTimerInterval('NetzfahrplanV4EmpfangAktualisieren', $enabled ? 10000 : 0);
|
||||
$this->aktualisiereNetzfahrplanV4Vorschau();
|
||||
@@ -52,26 +50,31 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
/** Called before the next operation upload, so the preceding completed plan can be seen. */
|
||||
private function empfangeNetzfahrplanV4(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv')) return;
|
||||
if (!$this->prognoseLernenAktiv()) return;
|
||||
$lock = 'ENELIX.V4.Receive.' . $this->InstanceID;
|
||||
if (!IPS_SemaphoreEnter($lock, 0)) return;
|
||||
try {
|
||||
$now = time();
|
||||
if ($now < $this->ReadAttributeInteger('NetzfahrplanV4NaechsterAbruf')) return;
|
||||
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
|
||||
$interval = ($session['kind'] ?? null) === 'local_active_test_consent' ? 45 : 60;
|
||||
$interval = $this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|
||||
|| ($session['kind'] ?? null) === 'local_active_test_consent' ? 45 : 60;
|
||||
$this->WriteAttributeInteger('NetzfahrplanV4NaechsterAbruf', $now + $interval);
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
throw new RuntimeException('Schatten-Sender fehlt oder alter Fahrplan ist eingeschaltet.');
|
||||
}
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) {
|
||||
throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
}
|
||||
$response = $this->netzfahrplanV4EmpfangHttp('GET', '');
|
||||
$receipt = time();
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || $this->ReadPropertyBoolean('NetzfahrplanAktiv')) {
|
||||
if (!$this->prognoseLernenAktiv()) {
|
||||
throw new RuntimeException('Freigabe waehrend des Abrufs geaendert.');
|
||||
}
|
||||
if (($response['installationId'] ?? null) !== $this->ReadAttributeString('LizenzInstallationID')) {
|
||||
throw new RuntimeException('Serverantwort gehoert zu einer anderen Installation.');
|
||||
}
|
||||
if (is_array($response['workflow'] ?? null)) {
|
||||
$this->WriteAttributeString('PrognoseV4Serverstatus', json_encode(
|
||||
['receivedAt' => $receipt, 'workflow' => $response['workflow']], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
// Validate the candidate before replacing the last accepted envelope. During an
|
||||
// active test a failed refresh may keep the previous envelope, but its original
|
||||
// receivedAt is never changed, so invalid traffic can never extend its lifetime.
|
||||
@@ -81,7 +84,7 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
// Only the known, non-secret protocol envelope is cached.
|
||||
$keys = ['receiverProtocolVersion', 'installationId', 'checkedAt', 'liveEnabled', 'fresh',
|
||||
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial'];
|
||||
'pending', 'settings', 'plan', 'families', 'lastRun', 'controlledTrial', 'workflow'];
|
||||
$safe = array_intersect_key($response, array_flip($keys));
|
||||
$this->WriteAttributeString('NetzfahrplanV4Empfang', json_encode(
|
||||
['receivedAt' => $receipt, 'response' => $safe], JSON_THROW_ON_ERROR));
|
||||
@@ -107,7 +110,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
$previous = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true);
|
||||
if (is_array($previous) && is_int($previous['receivedAt'] ?? null)) {
|
||||
$session = method_exists($this, 'v4ManagerTestSession') ? $this->v4ManagerTestSession() : [];
|
||||
$keepPrevious = (($session['kind'] ?? null) === 'local_active_test_consent')
|
||||
$keepPrevious = ($this->ReadPropertyBoolean('NetzfahrplanRegelungAktiv')
|
||||
|| ($session['kind'] ?? null) === 'local_active_test_consent')
|
||||
&& $previous['receivedAt'] <= time()
|
||||
&& time() - $previous['receivedAt'] <= NetzfahrplanV4Planpruefung::MAX_RECEIPT_AGE;
|
||||
}
|
||||
@@ -181,11 +185,15 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
&& 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')) {
|
||||
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldungCache')) {
|
||||
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (in_array(trim((string)$configured),['','{}'],true)
|
||||
&& method_exists($this,'GetV4RueckmeldungKonfiguration')) {
|
||||
$configured=$this->GetV4RueckmeldungKonfiguration($controller);
|
||||
}
|
||||
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);
|
||||
$f=json_decode(ENELIX_GetV4BatterieRueckmeldungCache($controller),true,32,JSON_THROW_ON_ERROR);
|
||||
$feedbackNow=time();
|
||||
if (($f['status']??null)==='unavailable') {
|
||||
$reason=is_string($f['reason']??null)?$f['reason']:'unknown';
|
||||
@@ -222,10 +230,9 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
public function GetNetzfahrplanV4Vorschau(): string
|
||||
{
|
||||
try {
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4EmpfangAktiv') || !$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
if (!$this->prognoseLernenAktiv()) {
|
||||
throw new RuntimeException('V4-Empfang ausgeschaltet.');
|
||||
}
|
||||
if ($this->ReadPropertyBoolean('NetzfahrplanAktiv')) throw new RuntimeException('Alter Fahrplan ist aktiv; V4-Vorschau gesperrt.');
|
||||
if (!$this->berechtigungLizenziert(Lizenzpruefung::NETZFAHRPLAN)) throw new RuntimeException('Netzfahrplanberechtigung fehlt.');
|
||||
$envelope = json_decode($this->ReadAttributeString('NetzfahrplanV4Empfang'), true, 64, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
||||
|
||||
@@ -40,7 +40,7 @@ trait ManagerNetzfahrplanV4Trait
|
||||
foreach (['Netzleistungsfaktor', 'Lastspitzengrenze', 'Einspeisegrenze'] as $key) {
|
||||
$manager[$key] = $this->ReadPropertyFloat($key);
|
||||
}
|
||||
$manager['V4ControlPlanSource'] = $this->leseJsonAttribut('Netzfahrplan')['sourceModel'] ?? 'legacy-unspecified';
|
||||
$manager['V4ControlPlanSource'] = 'netplan-v4';
|
||||
$manager['EinspeisebegrenzungAktiv'] = $this->ReadPropertyBoolean('EinspeisebegrenzungAktiv');
|
||||
$manager['NetzladenErlaubt'] = $this->ReadPropertyBoolean('NetzfahrplanV4NetzladenErlaubt');
|
||||
$manager['Monatsgrenzen'] = json_decode($this->ReadPropertyString('Monatsgrenzen'), true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -48,6 +48,20 @@ trait ManagerNetzfahrplanV4Trait
|
||||
$manager['V4BezugszaehlerQuellen'] = json_decode(
|
||||
$this->ReadPropertyString('NetzfahrplanV4BezugszaehlerQuellen'), true, 512, JSON_THROW_ON_ERROR
|
||||
);
|
||||
if ($manager['V4BezugszaehlerQuellen'] === [] && $this->ReadPropertyInteger('NetzbezugEnergieVariableID') > 0) {
|
||||
$meterID = $this->ReadPropertyInteger('NetzbezugEnergieVariableID');
|
||||
if (!IPS_VariableExists($meterID)) throw new RuntimeException('Wirkenergie-Bezugszaehler fehlt.');
|
||||
$meterObject = IPS_GetObject($meterID);
|
||||
if (!IPS_InstanceExists($meterObject['ParentID'])
|
||||
|| !in_array(IPS_GetInstance($meterObject['ParentID'])['ModuleInfo']['ModuleID'],
|
||||
[NetzfahrplanV4Geraeteabruf::MBUS_DEVICE, NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE,
|
||||
NetzfahrplanV4Geraeteabruf::MODBUS_ADDRESS], true)) {
|
||||
throw new RuntimeException('Wirkenergie-Bezugszaehler benoetigt eine direkte physische Messquelle.');
|
||||
}
|
||||
$manager['V4BezugszaehlerQuellen'] = [['VariableID' => $meterID,
|
||||
'ElternID' => $meterObject['ParentID'], 'Ident' => $meterObject['ObjectIdent'],
|
||||
'FaktorZuKWh' => $this->ReadPropertyFloat('NetzbezugEnergiefaktor'), 'Messgroesse' => 'WirkenergieBezug']];
|
||||
}
|
||||
$assets = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$cache = $this->leseJsonAttribut('VerbraucherCache');
|
||||
$controllers = [];
|
||||
@@ -64,11 +78,15 @@ trait ManagerNetzfahrplanV4Trait
|
||||
$entry = $cache[(string) $id] ?? [];
|
||||
$c['EmpfangenAm'] = $entry['EmpfangenAm'] ?? 0;
|
||||
$c['Verfuegbar'] = $entry['Daten']['Verfuegbar'] ?? false;
|
||||
if (function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
|
||||
if (function_exists('ENELIX_GetV4BatterieRueckmeldungCache')) {
|
||||
$configured = IPS_GetProperty($id, 'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (in_array(trim((string) $configured), ['', '{}'], true)
|
||||
&& method_exists($this, 'GetV4RueckmeldungKonfiguration')) {
|
||||
$configured = $this->GetV4RueckmeldungKonfiguration($id);
|
||||
}
|
||||
if (is_string($configured) && !in_array(trim($configured), ['', '{}'], true)) {
|
||||
try {
|
||||
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldung($id), true, 32, JSON_THROW_ON_ERROR);
|
||||
$feedback = json_decode(ENELIX_GetV4BatterieRueckmeldungCache($id), true, 32, JSON_THROW_ON_ERROR);
|
||||
$checkedAt = $feedback['checkedAt'] ?? null;
|
||||
if (($feedback['usableForTrial'] ?? false) === true
|
||||
&& ($feedback['deviceReadConfirmed'] ?? false) === true
|
||||
@@ -128,7 +146,7 @@ trait ManagerNetzfahrplanV4Trait
|
||||
|
||||
private function sendeNetzfahrplanV4(): void
|
||||
{
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4SchattenAktiv')) {
|
||||
if (!$this->prognoseLernenAktiv()) {
|
||||
$this->WriteAttributeString('NetzfahrplanV4Sendestatus', '{"status":"disabled"}');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Belevo\EnelixEMS;
|
||||
|
||||
use LogicException;
|
||||
|
||||
/** Form composition only. Existing configuration, control authority and backend stay unchanged. */
|
||||
/** Exactly two operator switches; legacy configuration is retained but not exposed. */
|
||||
final class ManagerPrognoseFormular
|
||||
{
|
||||
/** Stable identifiers remain for archives and integrations; operation belongs in Forecast. */
|
||||
@@ -22,45 +22,25 @@ final class ManagerPrognoseFormular
|
||||
|
||||
public static function startErlaubt(array $zustand): bool
|
||||
{
|
||||
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
|
||||
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $feld) {
|
||||
foreach (['prognoseAktiv', 'lizenziert', 'ready'] as $feld) {
|
||||
if (($zustand[$feld] ?? false) !== true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return ($zustand['legacyAktiv'] ?? true) === false
|
||||
&& ($zustand['aktivtestAktiv'] ?? true) === false
|
||||
&& isset($zustand['ende'], $zustand['jetzt'])
|
||||
&& is_int($zustand['ende']) && is_int($zustand['jetzt'])
|
||||
&& ($zustand['ende'] === 0 || $zustand['ende'] > $zustand['jetzt']);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function statustext(array $zustand): string
|
||||
{
|
||||
$text = ($zustand['aktivtestAktiv'] ?? false)
|
||||
? 'Netzfahrplan: befristeter Testbetrieb eingeschaltet.'
|
||||
: 'Netzfahrplan: Steuerung ausgeschaltet.';
|
||||
if ($zustand['legacyAktiv'] ?? false) {
|
||||
$text = 'Bisheriger Netzfahrplan eingeschaltet; neuer Netzfahrplan gesperrt.';
|
||||
}
|
||||
if (!($zustand['lizenziert'] ?? false)) {
|
||||
$text .= ' Netzfahrplanlizenz fehlt.';
|
||||
}
|
||||
$ende = $zustand['ende'] ?? 0;
|
||||
if (is_int($ende) && $ende > 0) {
|
||||
$text .= ' Bestehendes Testende: ' . gmdate('d.m.Y H:i:s', $ende) . ' UTC.';
|
||||
if ($ende <= ($zustand['jetzt'] ?? 0)) {
|
||||
$text .= ' Testfreigabe abgelaufen.';
|
||||
}
|
||||
}
|
||||
return $text . ' Kein freigegebener Dauerbetrieb. Bestehende Laufzeit-, Messwert- und Geraeteschutzpruefungen bleiben wirksam.';
|
||||
$states = ['disabled' => 'Aus', 'collecting' => 'Daten sammeln', 'learning' => 'Lernen',
|
||||
'ready' => 'Bereit', 'active' => 'Aktiv', 'interrupted' => 'Unterbrochen'];
|
||||
return ($states[$zustand['state'] ?? 'disabled'] ?? 'Status unbekannt') . ': '
|
||||
. ($zustand['reason'] ?? 'Aktuellen Status abwarten.');
|
||||
}
|
||||
|
||||
public static function integrieren(array $formular, array $zustand): array
|
||||
{
|
||||
$gefunden = false;
|
||||
$v4Konfiguriert = ($zustand['senderAktiv'] ?? false)
|
||||
|| ($zustand['empfangAktiv'] ?? false) || ($zustand['messdatenAktiv'] ?? false);
|
||||
foreach ($formular['elements'] as &$element) {
|
||||
if (($element['name'] ?? null) !== 'PrognoseForecast') {
|
||||
continue;
|
||||
@@ -69,45 +49,22 @@ final class ManagerPrognoseFormular
|
||||
throw new LogicException('Prognosebereich ist nicht eindeutig.');
|
||||
}
|
||||
$gefunden = true;
|
||||
foreach ($element['items'] as &$feld) {
|
||||
if (($feld['name'] ?? null) === 'NetzfahrplanAktiv' && $v4Konfiguriert) {
|
||||
// An active legacy controller remains visible so it can still be disabled.
|
||||
$feld['visible'] = (bool) ($zustand['legacyAktiv'] ?? false);
|
||||
$feld['enabled'] = (bool) ($zustand['legacyAktiv'] ?? false);
|
||||
$feld['caption'] = 'Bisheriger Netzfahrplan (konkurrierende Steuerung)';
|
||||
}
|
||||
}
|
||||
unset($feld);
|
||||
$element['items'] = [
|
||||
['type' => 'CheckBox', 'name' => 'PrognoseAktiv',
|
||||
'caption' => '1. Netzdaten senden und Prognosen lernen',
|
||||
'onChange' => "IPS_RequestAction(\$id, 'FormPrognoseAktiv', \$PrognoseAktiv);"],
|
||||
['type' => 'CheckBox', 'name' => 'NetzfahrplanRegelungAktiv',
|
||||
'caption' => '2. Batterie nach Netzfahrplan regeln',
|
||||
'enabled' => ($zustand['controlRequested'] ?? false) || self::startErlaubt($zustand)],
|
||||
['type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus', 'caption' => self::statustext($zustand)],
|
||||
['type' => 'Label', 'name' => 'PrognoseLizenzInformation',
|
||||
'caption' => 'V4-Netzziel und Batteriekorrektur. Andere Verbraucher bleiben in der bisherigen Managerregelung.'],
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseLizenzVerwalten',
|
||||
'caption' => 'Prognose und Netzfahrplan im Lizenzportal verwalten', 'link' => true,
|
||||
'onClick' => "echo 'https://license.enelix.ch';",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'CheckBox', 'name' => 'NetzfahrplanV4EmpfangAktiv',
|
||||
'caption' => 'Aktuellen Netzfahrplan empfangen und anzeigen',
|
||||
'enabled' => (bool) (($zustand['lizenziert'] ?? false) || ($zustand['empfangAktiv'] ?? false)),
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Label', 'name' => 'PrognoseNetzfahrplanStatus',
|
||||
'caption' => self::statustext($zustand),
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStart',
|
||||
'caption' => 'Netzfahrplan einschalten (Testbetrieb)',
|
||||
'enabled' => self::startErlaubt($zustand),
|
||||
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', true);",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanStop',
|
||||
'caption' => 'Netzfahrplan ausschalten', 'enabled' => true,
|
||||
'onClick' => "IPS_RequestAction(\$id, 'FormNetzfahrplanSchalten', false);",
|
||||
];
|
||||
$element['items'][] = [
|
||||
'type' => 'Button', 'name' => 'PrognoseNetzfahrplanPruefen',
|
||||
'caption' => 'Plan jetzt pruefen',
|
||||
'onClick' => "IPS_RequestAction(\$id, 'NetzfahrplanV4Empfangen', true);",
|
||||
];
|
||||
}
|
||||
unset($element);
|
||||
if (!$gefunden) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@ final class NetzfahrplanV4Bilanzierung
|
||||
public static function konfigurieren(array $c): array
|
||||
{
|
||||
$fields = ['version','sources','maxSkewSeconds','splitToleranceW'];
|
||||
$physicalOnly = ($c['storageMode'] ?? null) === 'physical_only';
|
||||
if (array_key_exists('storageMode', $c)) {
|
||||
if (!$physicalOnly) {
|
||||
throw new InvalidArgumentException('Unbekannte Speicherbilanzierung.');
|
||||
}
|
||||
$fields[] = 'storageMode';
|
||||
}
|
||||
if (count($c) !== count($fields) || array_diff($fields,array_keys($c)) || $c['version'] !== 1
|
||||
|| !is_array($c['sources']) || !self::listArray($c['sources']) || count($c['sources']) < 3 || count($c['sources']) > 80
|
||||
|| !is_int($c['maxSkewSeconds']) || $c['maxSkewSeconds'] < 0 || $c['maxSkewSeconds'] > 60) {
|
||||
@@ -57,9 +64,13 @@ final class NetzfahrplanV4Bilanzierung
|
||||
sort($s['dependsOn']);$keys[$s['key']]=true;$seen[$s['variableId']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
|
||||
}
|
||||
unset($s);
|
||||
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'],$roles['controlled_virtual'])) {
|
||||
if (($roles['grid']??0)!==1 || !isset($roles['pv'],$roles['physical_storage'])
|
||||
|| (!$physicalOnly && !isset($roles['controlled_virtual']))) {
|
||||
throw new InvalidArgumentException('Netz, PV, physische und steuerbare virtuelle Batterie muessen explizit zugeordnet sein.');
|
||||
}
|
||||
if ($physicalOnly && (isset($roles['controlled_virtual']) || isset($roles['external_virtual']))) {
|
||||
throw new InvalidArgumentException('Physische Speicherbilanz darf keine virtuellen Anteile enthalten.');
|
||||
}
|
||||
$graph=[];foreach ($c['sources'] as $s) $graph[$s['key']]=$s['dependsOn'];
|
||||
$visit=function($key,$stack=[]) use (&$visit,$graph): void {
|
||||
if (!array_key_exists($key,$graph) || isset($stack[$key])) throw new InvalidArgumentException('Fehlende oder zyklische Ursprungsquelle.');
|
||||
@@ -101,11 +112,13 @@ final class NetzfahrplanV4Bilanzierung
|
||||
$load=$totals['grid']+$totals['pv']-$totals['physical_storage'];
|
||||
$base=$load-$totals['flexible_load'];
|
||||
// This channel preserves unallocated dynamics instead of hiding them in house load.
|
||||
$residual=$totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
|
||||
$external=$totals['external_virtual']+$residual;
|
||||
$physicalOnly = ($c['storageMode'] ?? null) === 'physical_only';
|
||||
$residual=$physicalOnly ? 0.0 : $totals['physical_storage']-$totals['controlled_virtual']-$totals['external_virtual'];
|
||||
$external=$physicalOnly ? 0.0 : $totals['external_virtual']+$residual;
|
||||
if ($base<0) $issues[]='negative_base_load';
|
||||
if (abs($residual)>$c['splitToleranceW']) $issues[]='virtual_physical_allocation_mismatch';
|
||||
$reconstructed=$base+$totals['flexible_load']+$totals['controlled_virtual']+$external-$totals['pv'];
|
||||
$storage=$physicalOnly ? $totals['physical_storage'] : $totals['controlled_virtual']+$external;
|
||||
$reconstructed=$base+$totals['flexible_load']+$storage-$totals['pv'];
|
||||
$identity=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
return ['version'=>1,'status'=>$issues===[]?'consistent_candidate':'quality_hold',
|
||||
'capturedAt'=>gmdate('c',$now),'sourceObservedFrom'=>gmdate('c',min($times)),'sourceObservedUntil'=>gmdate('c',max($times)),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
||||
|
||||
/** Pure defaults for ordinary, separately metered AC plants. No activation or I/O.
|
||||
* Existing specialist mappings must be retained by the caller, not passed here.
|
||||
*/
|
||||
final class NetzfahrplanV4Einrichtung
|
||||
{
|
||||
private static function identifier($value, string $label): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', trim($value))) {
|
||||
throw new InvalidArgumentException($label . ': eindeutige Anlagenkennung fehlt.');
|
||||
}
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
private static function objectId($value, string $label): int
|
||||
{
|
||||
if (!is_int($value) || $value < 1 || $value > 99999) {
|
||||
throw new InvalidArgumentException($label . ': gueltige Objektzuordnung fehlt.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function factor($value): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|
||||
|| $value == 0 || abs($value) > 1.0e6) {
|
||||
throw new InvalidArgumentException('Expliziter Leistungsfaktor fehlt oder ist ungueltig.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
private static function source(string $key, string $role, $id, $factor, int $age, callable $read): array
|
||||
{
|
||||
$id = self::objectId($id, $role);
|
||||
try {
|
||||
$object = $read($id);
|
||||
} catch (Throwable $e) {
|
||||
// Reader exceptions may contain connection settings; never propagate them.
|
||||
throw new InvalidArgumentException('Messquelle ' . $id . ' ist nicht verfuegbar.');
|
||||
}
|
||||
if (!is_array($object) || !in_array($object['type'] ?? null, [1, 2], true)
|
||||
|| !is_int($object['parentID'] ?? null) || $object['parentID'] < 1 || $object['parentID'] > 99999
|
||||
|| !is_string($object['ident'] ?? null) || strlen($object['ident']) > 100) {
|
||||
throw new InvalidArgumentException('Messquelle ' . $id . ' ist keine eindeutig zugeordnete numerische Variable.');
|
||||
}
|
||||
if (($object['virtual'] ?? false) !== false || ($object['sharedStorage'] ?? false) !== false) {
|
||||
throw new InvalidArgumentException('Virtuelle oder geteilte Speicher benoetigen eine explizite Messzuordnung.');
|
||||
}
|
||||
return ['key' => $key, 'role' => $role, 'variableId' => $id,
|
||||
'parentId' => $object['parentID'], 'ident' => $object['ident'],
|
||||
'factorToW' => self::factor($factor), 'maxAgeSeconds' => $age, 'dependsOn' => []];
|
||||
}
|
||||
|
||||
/** objectReader(int): {parentID:int, ident:string, type:int, virtual?:bool, sharedStorage?:bool}.
|
||||
* type is IPS VariableType (1=int, 2=float), not ObjectType. Readers verify
|
||||
* the object really is a variable and reject derived/virtual source parents.
|
||||
* No values, timestamps, full instance settings or credentials are needed.
|
||||
*
|
||||
* @return array{capture:array,dataset:array,datasetId:string,mappingSha256:string,inventorySha256:string}
|
||||
*/
|
||||
public static function erstellen(
|
||||
string $installationId,
|
||||
int $managerId,
|
||||
array $manager,
|
||||
array $inverters,
|
||||
array $batteries,
|
||||
callable $objectReader
|
||||
): array {
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $installationId)) {
|
||||
throw new InvalidArgumentException('Gueltige Installationskennung erforderlich.');
|
||||
}
|
||||
self::objectId($managerId, 'Manager');
|
||||
$age = $manager['MesswertMaxAlter'] ?? null;
|
||||
if (!is_int($age) || $age < 1) {
|
||||
throw new InvalidArgumentException('Maximales Messwertalter fehlt.');
|
||||
}
|
||||
$age = min(60, $age);
|
||||
if (($manager['SDLAktiv'] ?? false) !== false || ($manager['SDLLeistungVariableID'] ?? 0) !== 0) {
|
||||
throw new InvalidArgumentException('SDL-Anlage benoetigt eine explizite physische Messzuordnung.');
|
||||
}
|
||||
if (array_keys($inverters) !== array_keys(array_values($inverters))
|
||||
|| array_keys($batteries) !== array_keys(array_values($batteries))
|
||||
|| $inverters === [] || $batteries === [] || count($inverters) + count($batteries) > 79) {
|
||||
throw new InvalidArgumentException('Vollstaendige Wechselrichter- und Batterietopologie erforderlich.');
|
||||
}
|
||||
$sources = [self::source('grid', 'grid', $manager['NetzleistungVariableID'] ?? null,
|
||||
$manager['Netzleistungsfaktor'] ?? null, $age, $objectReader)];
|
||||
$inverterMap = [];
|
||||
foreach ($inverters as $inverter) {
|
||||
if (!is_array($inverter)) {
|
||||
throw new InvalidArgumentException('Ungueltiger Wechselrichtereintrag.');
|
||||
}
|
||||
$id = self::identifier($inverter['ID'] ?? null, 'Wechselrichter');
|
||||
if (isset($inverterMap[$id]) || !in_array($inverter['Typ'] ?? null, ['pv', 'battery'], true)) {
|
||||
throw new InvalidArgumentException('Doppelte oder hybride Wechselrichter benoetigen eine explizite Messzuordnung.');
|
||||
}
|
||||
$inverterMap[$id] = $inverter['Typ'];
|
||||
if ($inverter['Typ'] === 'pv') {
|
||||
$sources[] = self::source('pv_' . substr(hash('sha256', $id), 0, 24), 'pv',
|
||||
$inverter['IstleistungVariableID'] ?? null, $inverter['Istleistungsfaktor'] ?? null, $age, $objectReader);
|
||||
}
|
||||
}
|
||||
$batteryMap = [];
|
||||
$usedInverters = [];
|
||||
foreach ($batteries as $battery) {
|
||||
if (!is_array($battery)) {
|
||||
throw new InvalidArgumentException('Ungueltiger Batterieeintrag.');
|
||||
}
|
||||
$id = self::identifier($battery['ID'] ?? null, 'Batterie');
|
||||
$inverterId = self::identifier($battery['WechselrichterID'] ?? null, 'Batteriewechselrichter');
|
||||
if (isset($batteryMap[$id]) || ($battery['Kopplung'] ?? null) !== 'ac'
|
||||
|| ($inverterMap[$inverterId] ?? null) !== 'battery' || isset($usedInverters[$inverterId])) {
|
||||
throw new InvalidArgumentException('Batterie benoetigt einen eigenen AC-Wandler und eine eindeutige Leistungsmessung.');
|
||||
}
|
||||
if (($battery['Virtuell'] ?? false) !== false || ($battery['SDLAktiv'] ?? false) !== false) {
|
||||
throw new InvalidArgumentException('Virtuelle oder SDL-Batterie benoetigt eine explizite Messzuordnung.');
|
||||
}
|
||||
$batteryMap[$id] = $inverterId;
|
||||
$usedInverters[$inverterId] = true;
|
||||
$sources[] = self::source('battery_' . substr(hash('sha256', $id), 0, 24), 'physical_storage',
|
||||
$battery['LeistungVariableID'] ?? null, $battery['Leistungsfaktor'] ?? null, $age, $objectReader);
|
||||
}
|
||||
foreach ($inverterMap as $id => $type) {
|
||||
if ($type === 'battery' && !isset($usedInverters[$id])) {
|
||||
throw new InvalidArgumentException('Nicht zugeordneter Batteriewechselrichter in der Anlagenbilanz.');
|
||||
}
|
||||
}
|
||||
$accounting = NetzfahrplanV4Bilanzierung::konfigurieren([
|
||||
'version' => 1, 'sources' => $sources, 'maxSkewSeconds' => min(30, $age),
|
||||
'splitToleranceW' => 0.0, 'storageMode' => 'physical_only',
|
||||
]);
|
||||
ksort($inverterMap);
|
||||
ksort($batteryMap);
|
||||
$inventory = ['version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
|
||||
'inverters' => $inverterMap, 'batteries' => $batteryMap, 'sources' => $accounting['sources']];
|
||||
$inventoryHash = hash('sha256', json_encode($inventory, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
|
||||
$capture = NetzfahrplanV4Messaufnahme::configuration([
|
||||
'version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
|
||||
'reportedInventorySha256' => $inventoryHash, 'accounting' => $accounting, 'extraSources' => [],
|
||||
]);
|
||||
$mappingHash = hash('sha256', json_encode($capture, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
|
||||
$datasetId = 'v4-' . substr($mappingHash, 0, 32);
|
||||
$dataset = ['datasetId' => $datasetId, 'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash,
|
||||
'sources' => array_map(static fn(array $s): array => [
|
||||
'key' => $s['key'], 'variableId' => $s['variableId'], 'role' => $s['role'],
|
||||
'factorToW' => $s['factorToW'], 'maxAgeSeconds' => $s['maxAgeSeconds'],
|
||||
], $capture['accounting']['sources']),
|
||||
'formula' => 'physical_sum_v1', 'solarReference' => null, 'minimumCoverage' => 0.95,
|
||||
'maximumGapSeconds' => 5, 'minimumTrainingHours' => 24, 'historyDays' => 30];
|
||||
return ['capture' => $capture, 'dataset' => $dataset, 'datasetId' => $datasetId,
|
||||
'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash];
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ final class NetzfahrplanV4Planpruefung
|
||||
'stepValidUntil' => $point['validUntil'], 'gridTargetW' => (float) $point['gridTargetW'],
|
||||
'plannedBatteryW' => (float) $point['batteryTargetW'], 'previewBatteryW' => $clipped,
|
||||
'actualGridW' => $grid, 'actualBatteryW' => $battery, 'expectedGridW' => $expectedGrid,
|
||||
'maxChargeW' => $charge, 'maxDischargeW' => $discharge,
|
||||
'unmetGridLimitW' => $violation, 'reasons' => $reasons, 'inputWarnings' => $p['warnings'] ?? [],
|
||||
'validityNote' => 'Nur Momentvorschau; keine Stellfreigabe und keine nachgewiesene Netzreaktion.'];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__ . '/Nachrichtenvertrag.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Pure correction of an ordinary Manager allocation. No commands or permissions.
|
||||
* The caller separately enforces licence, user consent and healthy model readiness.
|
||||
*/
|
||||
final class NetzfahrplanV4Regelung
|
||||
{
|
||||
private static function number($value, string $label): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || abs($value) > 1.0e9) {
|
||||
throw new InvalidArgumentException($label . ': endlicher Zahlenwert erforderlich.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
private static function objectId($value): int
|
||||
{
|
||||
if (!is_int($value) || $value < 1 || $value > 99999) {
|
||||
throw new InvalidArgumentException('Eindeutige Instanzzuordnung erforderlich.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** A legal interval or singleton is selected, never a gap between offers. */
|
||||
private static function offeredTarget(array $offers, float $desired, float $lower, float $upper): int
|
||||
{
|
||||
$best = null;
|
||||
foreach ($offers as $offer) {
|
||||
$from = is_int($offer) ? $offer : $offer['Von_W'];
|
||||
$until = is_int($offer) ? $offer : $offer['Bis_W'];
|
||||
$low = (int) ceil(max($lower, $from));
|
||||
$high = (int) floor(min($upper, $until));
|
||||
if ($low > $high) {
|
||||
continue;
|
||||
}
|
||||
$candidate = max($low, min($high, (int) $desired));
|
||||
if ($best === null || [abs($candidate - $desired), abs($candidate), $candidate]
|
||||
< [abs($best - $desired), abs($best), $best]) {
|
||||
$best = $candidate;
|
||||
}
|
||||
}
|
||||
if ($best === null || !Nachrichtenvertrag::enthaeltLeistungswert($offers, $best)) {
|
||||
throw new InvalidArgumentException('Kein angebotener Batteriesollwert innerhalb aller Schutzgrenzen.');
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
/** Read-only readiness checks. Consumer synchronization and offers are checked at allocation time.
|
||||
* @return array{checked:array,feedback:array,preview:array}
|
||||
*/
|
||||
public static function pruefen(
|
||||
array $envelope,
|
||||
string $installationId,
|
||||
int $managerId,
|
||||
array $operation,
|
||||
array $actual,
|
||||
array $binding,
|
||||
int $now
|
||||
): array {
|
||||
self::objectId($managerId);
|
||||
$batteryId = self::objectId($binding['batteryInstanceId'] ?? null);
|
||||
$assetId = $binding['assetId'] ?? null;
|
||||
$watchdog = $binding['watchdogEvidence'] ?? null;
|
||||
if ($now < 1 || $batteryId === $managerId
|
||||
|| !is_string($assetId) || $assetId === '' || strlen($assetId) > 100
|
||||
|| !is_string($watchdog) || strlen(trim($watchdog)) < 8 || strlen($watchdog) > 256
|
||||
|| strcasecmp(trim($watchdog), 'explicit_test_plant_watchdog_waiver') === 0) {
|
||||
throw new InvalidArgumentException('Batteriezuordnung oder Geraete-Watchdog fehlen.');
|
||||
}
|
||||
foreach (['installationId' => $installationId, 'managerId' => $managerId] as $key => $value) {
|
||||
if (isset($binding[$key]) && $binding[$key] !== $value) {
|
||||
throw new InvalidArgumentException('Widerspruechliche lokale Anlagenzuordnung.');
|
||||
}
|
||||
$binding[$key] = $value;
|
||||
}
|
||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)
|
||||
|| !is_array($operation['batteries'] ?? null)
|
||||
|| array_keys($operation['batteries']) !== array_keys(array_values($operation['batteries']))
|
||||
|| count($operation['batteries']) !== 1 || ($operation['batteries'][0]['id'] ?? null) !== $assetId
|
||||
|| count($actual) !== 1 || !array_key_exists($assetId, $actual)
|
||||
|| array_key_exists('localPredictedOtherDeltaW', $operation)) {
|
||||
throw new InvalidArgumentException('Vollstaendiger unveraenderter Einzelbatterie-Kontext erforderlich.');
|
||||
}
|
||||
$feedback = $operation['localFeedback'][$assetId] ?? [];
|
||||
if (!is_array($feedback)) {
|
||||
throw new InvalidArgumentException('Bestaetigte physische Batterierueckmeldung fehlt.');
|
||||
}
|
||||
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $binding, $now);
|
||||
if (($feedback['estimated'] ?? null) !== false || ($feedback['separatelyMeasured'] ?? null) !== true
|
||||
|| abs(self::number($actual[$assetId], 'Batterie-Istleistung') - $feedback['batteryW']) > 0.000001
|
||||
|| abs(self::number($operation['gridW'] ?? null, 'Netz-Istleistung') - $feedback['gridW']) > 0.001) {
|
||||
throw new InvalidArgumentException('Direkt gemessene Batterie- und Netzleistung aus derselben Aufnahme erforderlich.');
|
||||
}
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen(
|
||||
$envelope['response'], $installationId, $operation, $now, $envelope['receivedAt']
|
||||
);
|
||||
$quality = $checked['plan']['inputQuality'] ?? [];
|
||||
if (($quality['loadBasis'] ?? null) !== 'base_load'
|
||||
|| !is_string($quality['accountingEvidenceId'] ?? null) || strlen($quality['accountingEvidenceId']) < 8) {
|
||||
throw new InvalidArgumentException('Nachvollziehbare physische Verbrauchsbilanz fehlt.');
|
||||
}
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
||||
if ($preview['unmetGridLimitW'] > 1) {
|
||||
throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.');
|
||||
}
|
||||
return ['checked' => $checked, 'feedback' => $feedback, 'preview' => $preview];
|
||||
}
|
||||
|
||||
/** @return array{allocation:array,report:array} */
|
||||
public static function korrigieren(
|
||||
array $envelope,
|
||||
string $installationId,
|
||||
int $managerId,
|
||||
array $operation,
|
||||
array $actual,
|
||||
array $allocation,
|
||||
array $consumers,
|
||||
bool $synchronized,
|
||||
array $binding,
|
||||
int $now
|
||||
): array {
|
||||
$verified = self::pruefen($envelope, $installationId, $managerId, $operation, $actual, $binding, $now);
|
||||
$checked = $verified['checked'];
|
||||
$feedback = $verified['feedback'];
|
||||
$batteryId = $binding['batteryInstanceId'];
|
||||
$assetId = $binding['assetId'];
|
||||
if (!$synchronized || !is_array($allocation['Sollwerte'] ?? null)
|
||||
|| !array_key_exists($batteryId, $allocation['Sollwerte'])) {
|
||||
throw new InvalidArgumentException('Verbraucher nicht synchronisiert oder Batterie nicht in Zuteilung.');
|
||||
}
|
||||
if (array_keys($consumers) !== array_keys(array_values($consumers)) || count($consumers) > 500) {
|
||||
throw new InvalidArgumentException('Ungueltige Verbraucherliste.');
|
||||
}
|
||||
$byId = [];
|
||||
foreach ($consumers as $consumer) {
|
||||
if (!is_array($consumer) || !is_array($consumer['Daten'] ?? null)) {
|
||||
throw new InvalidArgumentException('Verbraucherdaten fehlen.');
|
||||
}
|
||||
$id = self::objectId($consumer['InstanzID'] ?? null);
|
||||
if (isset($byId[$id])) {
|
||||
throw new InvalidArgumentException('Doppelte Verbraucherzuordnung.');
|
||||
}
|
||||
Nachrichtenvertrag::pruefeVerbraucherdaten($consumer['Daten']);
|
||||
$d = $consumer['Daten'];
|
||||
if ($d['Kopf']['AbsenderID'] !== $id || $d['Kopf']['EmpfaengerID'] !== $managerId
|
||||
|| $d['Kopf']['Zeitpunkt'] > $now || $now - $d['Kopf']['Zeitpunkt'] > 60) {
|
||||
throw new InvalidArgumentException('Verbrauchermeldung falsch zugeordnet oder veraltet.');
|
||||
}
|
||||
$byId[$id] = $d;
|
||||
}
|
||||
$battery = $byId[$batteryId] ?? null;
|
||||
if ($battery === null || !$battery['Verfuegbar'] || !$battery['AenderungMoeglich']) {
|
||||
throw new InvalidArgumentException('Batterie fehlt oder bietet keine Aenderung an.');
|
||||
}
|
||||
$delta = 0.0;
|
||||
foreach ($allocation['Sollwerte'] as $id => $target) {
|
||||
if (!is_int($id) || !isset($byId[$id]) || !is_int($target)) {
|
||||
throw new InvalidArgumentException('Sollwert ohne eindeutigen Verbraucher.');
|
||||
}
|
||||
if ($id === $batteryId) {
|
||||
continue;
|
||||
}
|
||||
if ($byId[$id]['Leistungsquelle'] !== Nachrichtenvertrag::LEISTUNGSQUELLE_GEMESSEN) {
|
||||
throw new InvalidArgumentException('Istleistung anderer zugeteilter Verbraucher muss gemessen sein.');
|
||||
}
|
||||
$delta += self::number($target, 'Anderer Sollwert') - self::number($byId[$id]['Istleistung_W'], 'Andere Istleistung');
|
||||
}
|
||||
$delta = self::number($delta, 'Andere Verbraucherabweichung');
|
||||
$operation['gridW'] = self::number($feedback['gridW'] + $delta, 'Prognostizierte Netzleistung');
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
||||
if ($preview['unmetGridLimitW'] > 1) {
|
||||
throw new InvalidArgumentException('Netzgrenze durch Batterie allein nicht erreichbar.');
|
||||
}
|
||||
$lower = -self::number($preview['maxDischargeW'] ?? null, 'Vorschau-Entladegrenze');
|
||||
$upper = self::number($preview['maxChargeW'] ?? null, 'Vorschau-Ladegrenze');
|
||||
$desired = self::number($preview['previewBatteryW'], 'Vorschau-Sollwert');
|
||||
$residual = $operation['gridW'] - $actual[$assetId];
|
||||
$import = $operation['limits']['importW'];
|
||||
$export = $operation['limits']['exportW'];
|
||||
$month = (int) (new DateTimeImmutable('@' . $now))->setTimezone(new DateTimeZone('Europe/Zurich'))->format('n');
|
||||
$monthly = $operation['limits']['managerMonthLimitsW'][$month] ?? null;
|
||||
foreach ([$monthly, $checked['point']['importLimitW']] as $limit) {
|
||||
if ($limit !== null) {
|
||||
$import = $import === null ? $limit : min($import, $limit);
|
||||
}
|
||||
}
|
||||
if ($checked['point']['exportLimitW'] !== null) {
|
||||
$export = $export === null ? $checked['point']['exportLimitW'] : min($export, $checked['point']['exportLimitW']);
|
||||
}
|
||||
if ($import !== null) {
|
||||
$upper = min($upper, $import - $residual);
|
||||
}
|
||||
if ($export !== null) {
|
||||
$lower = max($lower, -$export - $residual);
|
||||
}
|
||||
// An offer gap must not turn hold into charging or reverse the computed intent.
|
||||
if ($desired >= 0) {
|
||||
$lower = max($lower, 0.0);
|
||||
}
|
||||
if ($desired <= 0) {
|
||||
$upper = min($upper, 0.0);
|
||||
}
|
||||
$target = self::offeredTarget($battery['Leistungswerte_W'], $desired, $lower, $upper);
|
||||
$old = self::number($allocation['Sollwerte'][$batteryId], 'Bisheriger Batteriesollwert');
|
||||
$allocation['Sollwerte'][$batteryId] = $target;
|
||||
if (array_key_exists('Restabweichung_W', $allocation)) {
|
||||
$allocation['Restabweichung_W'] = (int) (self::number($allocation['Restabweichung_W'], 'Restabweichung') - ($target - $old));
|
||||
}
|
||||
return ['allocation' => $allocation, 'report' => [
|
||||
'status' => 'allocation_corrected', 'installationId' => $installationId, 'managerId' => $managerId,
|
||||
'batteryInstanceId' => $batteryId, 'assetId' => $assetId, 'planId' => $checked['plan']['planId'],
|
||||
'checkedAt' => gmdate('c', $now), 'stepValidUntil' => $checked['point']['validUntil'],
|
||||
'targetW' => $target, 'gridTargetW' => $preview['gridTargetW'], 'expectedGridW' => $residual + $target,
|
||||
'otherConsumerPredictedDeltaW' => $delta, 'physicalResponseVerified' => false,
|
||||
]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Pure feedback binding for an already verified automatic physical capture. */
|
||||
final class NetzfahrplanV4RueckmeldungEinrichtung
|
||||
{
|
||||
/**
|
||||
* The caller verifies the current capture against the automatic plant setup,
|
||||
* including supported physical source parents. Custom mappings stay separate.
|
||||
* $assets is AnlagenBatterien; $controllers contains every assigned active
|
||||
* battery controller with its existing public measurement properties only.
|
||||
* No device read, configuration write, watchdog proof or permission is created.
|
||||
*/
|
||||
public static function erstellen(
|
||||
array $capture,
|
||||
string $installationId,
|
||||
int $managerId,
|
||||
int $batteryId,
|
||||
array $assets,
|
||||
array $controllers
|
||||
): array {
|
||||
$capture = NetzfahrplanV4Messaufnahme::configuration($capture);
|
||||
if (($capture['installationId'] ?? null) !== $installationId
|
||||
|| ($capture['managerId'] ?? null) !== $managerId
|
||||
|| ($capture['accounting']['storageMode'] ?? null) !== 'physical_only'
|
||||
|| $capture['extraSources'] !== []) {
|
||||
throw new InvalidArgumentException('feedback_auto_capture_binding_invalid');
|
||||
}
|
||||
if (array_keys($assets) !== array_keys(array_values($assets)) || count($assets) !== 1 || !is_array($assets[0])
|
||||
|| array_keys($controllers) !== array_keys(array_values($controllers)) || count($controllers) !== 1 || !is_array($controllers[0])) {
|
||||
throw new InvalidArgumentException('feedback_auto_battery_assignment_ambiguous');
|
||||
}
|
||||
$asset = $assets[0];
|
||||
$controller = $controllers[0];
|
||||
$assetId = $asset['ID'] ?? null;
|
||||
if (!is_string($assetId) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', trim($assetId))
|
||||
|| ($asset['Kopplung'] ?? null) !== 'ac' || ($asset['Virtuell'] ?? false) !== false
|
||||
|| ($asset['SDLAktiv'] ?? false) !== false || ($controller['InstanzID'] ?? null) !== $batteryId
|
||||
|| ($controller['Aktiv'] ?? true) !== true) {
|
||||
throw new InvalidArgumentException('feedback_auto_battery_binding_invalid');
|
||||
}
|
||||
foreach (['LeistungVariableID', 'SOCVariableID'] as $key) {
|
||||
if (!is_int($asset[$key] ?? null) || $asset[$key] < 1 || $asset[$key] > 99999) {
|
||||
throw new InvalidArgumentException('feedback_auto_battery_measurements_missing');
|
||||
}
|
||||
}
|
||||
if (($controller['IstleistungVariableID'] ?? null) !== $asset['LeistungVariableID']
|
||||
|| ($controller['LadezustandVariableID'] ?? null) !== $asset['SOCVariableID']
|
||||
|| $asset['SOCVariableID'] === $asset['LeistungVariableID']) {
|
||||
throw new InvalidArgumentException('feedback_auto_controller_measurements_differ');
|
||||
}
|
||||
if ((!is_int($asset['Leistungsfaktor'] ?? null) && !is_float($asset['Leistungsfaktor'] ?? null))
|
||||
|| (float) $asset['Leistungsfaktor'] !== 1.0) {
|
||||
throw new InvalidArgumentException('feedback_auto_controller_units_differ');
|
||||
}
|
||||
$maxAge = $controller['MesswertMaxAlter'] ?? null;
|
||||
if (!is_int($maxAge) || $maxAge < 1) {
|
||||
throw new InvalidArgumentException('feedback_auto_controller_freshness_invalid');
|
||||
}
|
||||
$maxAge = min(60, $maxAge);
|
||||
$sources = [];
|
||||
$roles = [];
|
||||
foreach ($capture['accounting']['sources'] as $source) {
|
||||
if ($source['dependsOn'] !== [] || !in_array($source['role'], ['grid', 'pv', 'physical_storage'], true)) {
|
||||
throw new InvalidArgumentException('feedback_auto_derived_source_refused');
|
||||
}
|
||||
if (!in_array($source['role'], ['grid', 'physical_storage'], true)) {
|
||||
continue;
|
||||
}
|
||||
$roles[$source['role']] = ($roles[$source['role']] ?? 0) + 1;
|
||||
// The ordinary battery module consumes these same variables as W.
|
||||
if ($source['factorToW'] !== 1.0) {
|
||||
throw new InvalidArgumentException('feedback_auto_controller_units_differ');
|
||||
}
|
||||
if (($source['role'] === 'grid' && $source['variableId'] !== ($controller['NetzleistungVariableID'] ?? null))
|
||||
|| ($source['role'] === 'physical_storage' && $source['variableId'] !== $asset['LeistungVariableID'])
|
||||
|| $source['variableId'] === $asset['SOCVariableID']) {
|
||||
throw new InvalidArgumentException('feedback_auto_source_binding_invalid');
|
||||
}
|
||||
$expectedKey = $source['role'] === 'grid' ? 'grid' : 'battery_' . substr(hash('sha256', trim($assetId)), 0, 24);
|
||||
if ($source['key'] !== $expectedKey) {
|
||||
throw new InvalidArgumentException('feedback_auto_source_identity_changed');
|
||||
}
|
||||
$maxAge = min($maxAge, $source['maxAgeSeconds']);
|
||||
$sources[] = [
|
||||
'key' => $source['key'], 'role' => $source['role'] === 'grid' ? 'grid' : 'physical',
|
||||
'variableId' => $source['variableId'], 'parentId' => $source['parentId'],
|
||||
'ident' => $source['ident'], 'factorToW' => $source['factorToW'],
|
||||
];
|
||||
}
|
||||
if (($roles['grid'] ?? 0) !== 1 || ($roles['physical_storage'] ?? 0) !== 1) {
|
||||
throw new InvalidArgumentException('feedback_auto_physical_sources_ambiguous');
|
||||
}
|
||||
return NetzfahrplanV4Rueckmeldung::configuration([
|
||||
'version' => 1, 'installationId' => $installationId, 'assetId' => trim($assetId),
|
||||
'managerId' => $managerId, 'batteryInstanceId' => $batteryId,
|
||||
'mode' => 'physical_sum', 'allowEstimatedForTrial' => false,
|
||||
'maxAgeSeconds' => $maxAge,
|
||||
'maxSkewSeconds' => min(30, $maxAge, $capture['accounting']['maxSkewSeconds']),
|
||||
'idleToleranceW' => 0.0, 'trackingToleranceW' => 0.0, 'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user