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

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