feat(control): connect physical feedback to bounded V4 command and fallback paths
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__.'/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Shared physical reading used by receiver and battery immediately before a trial command. */
|
||||
trait BatterieNetzfahrplanV4RueckmeldungTrait
|
||||
{
|
||||
private function registriereV4Rueckmeldung(): void
|
||||
{
|
||||
$this->RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration', '{}');
|
||||
}
|
||||
|
||||
private function v4BatterieRueckmeldung(): array
|
||||
{
|
||||
$json=$this->ReadPropertyString('NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (strlen($json)>65536) throw new \RuntimeException('feedback_configuration_too_large');
|
||||
$c=json_decode($json,true,32,JSON_THROW_ON_ERROR);
|
||||
if (!is_array($c) || ($c['batteryInstanceId']??null)!==$this->InstanceID) throw new \RuntimeException('feedback_battery_binding_invalid');
|
||||
if (!in_array($c['managerId']??0,$this->zugeordneteManagerIDs(),true)) throw new \RuntimeException('feedback_manager_binding_invalid');
|
||||
return NetzfahrplanV4Rueckmeldung::read($c,static function(int $id):array {
|
||||
if (!IPS_VariableExists($id)) throw new \RuntimeException('feedback_variable_missing');
|
||||
$v=IPS_GetVariable($id);$o=IPS_GetObject($id);$value=GetValue($id);
|
||||
return ['value'=>$value,'updated'=>$v['VariableUpdated'],'changed'=>$v['VariableChanged'],'parentID'=>$o['ParentID'],'ident'=>$o['ObjectIdent']];
|
||||
},time());
|
||||
}
|
||||
|
||||
/** Read only. A returned current value is NOT actuator permission. */
|
||||
public function GetV4BatterieRueckmeldung(): string
|
||||
{
|
||||
try { $r=$this->v4BatterieRueckmeldung(); }
|
||||
catch (\Throwable $e) { $r=['kind'=>'local_physical_feedback','status'=>'unavailable','reason'=>substr($e->getMessage(),0,200),
|
||||
'batteryW'=>null,'gridW'=>null,'usableForTrial'=>false,'canDispatch'=>false]; }
|
||||
return json_encode($r,JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Regeltest.php';
|
||||
require_once __DIR__ . '/BatterieNetzfahrplanV4RueckmeldungTrait.php';
|
||||
|
||||
/** Dedicated short-lease battery input. Disabled until explicitly armed on BOTH modules. */
|
||||
trait BatterieNetzfahrplanV4TestTrait
|
||||
{
|
||||
use BatterieNetzfahrplanV4RueckmeldungTrait;
|
||||
private function registriereV4BatterieTest(): void
|
||||
{
|
||||
$this->registriereV4Rueckmeldung();
|
||||
$this->RegisterPropertyBoolean('NetzfahrplanV4RegeltestErlaubt', false);
|
||||
$this->RegisterAttributeString('NetzfahrplanV4LetzteTestsitzung', '');
|
||||
$this->RegisterPropertyString('NetzfahrplanV4GeraeteWatchdogNachweis', '');
|
||||
@@ -69,6 +72,7 @@ trait BatterieNetzfahrplanV4TestTrait
|
||||
if (!$this->GetValue('Aktiv') || $this->ReadPropertyInteger('Batteriemanagement') !== 2) {
|
||||
throw new RuntimeException('Batterie nicht unter ENELIX-Regelung.');
|
||||
}
|
||||
NetzfahrplanV4Rueckmeldung::requireTrial($this->v4BatterieRueckmeldung(), $s, $now);
|
||||
$this->WriteAttributeString('NetzfahrplanV4LetzteTestsitzung', $s['sessionId']);
|
||||
$s['active'] = true;$s['lastSequence'] = 0;$s['lastWallClockAt'] = $now;
|
||||
$s['monotonicDeadline'] = hrtime(true) / 1e9 + $end - $now;
|
||||
@@ -157,6 +161,8 @@ trait BatterieNetzfahrplanV4TestTrait
|
||||
$s = $this->v4BatterieTestSitzung();$mono = hrtime(true) / 1e9;
|
||||
try {
|
||||
if (!$this->ReadPropertyBoolean('NetzfahrplanV4RegeltestErlaubt')) throw new RuntimeException('Lokale Testfreigabe entzogen.');
|
||||
$feedback = $this->v4BatterieRueckmeldung();
|
||||
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $s, $now);
|
||||
$c = json_decode($this->GetBuffer('V4BatteryTrialCommand') ?: '{}', true);
|
||||
if ($now >= $s['expiresAt'] || $mono >= $s['monotonicDeadline'] || $now < $s['lastWallClockAt']) throw new RuntimeException('Testsitzung abgelaufen/Zeitsprung.');
|
||||
if (!$c) {
|
||||
@@ -175,6 +181,13 @@ trait BatterieNetzfahrplanV4TestTrait
|
||||
if ($last * $w < 0) $w = 0;
|
||||
elseif (abs($w) > abs($last)) $w = $last;
|
||||
}
|
||||
// Final hardware-policy/ramp clipping must not invalidate the binding or grid limits.
|
||||
if (($c['feedbackConfigHash'] ?? null) !== $feedback['configHash']) throw new RuntimeException('feedback_configuration_changed');
|
||||
$expectedGrid = $feedback['gridW'] - $feedback['batteryW'] + $w;
|
||||
if (($c['importLimitW'] !== null && $expectedGrid > $c['importLimitW'] + 1)
|
||||
|| ($c['exportLimitW'] !== null && -$expectedGrid > $c['exportLimitW'] + 1)) {
|
||||
throw new RuntimeException('final_battery_clamp_cannot_meet_grid_limit');
|
||||
}
|
||||
$s['lastWallClockAt'] = $now;
|
||||
$this->SetBuffer('V4BatteryTrialSession', json_encode($s, JSON_THROW_ON_ERROR));
|
||||
return $w;
|
||||
|
||||
@@ -8,6 +8,7 @@ use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Independent preview only: no SendData, consumer RequestAction or legacy plan writes. */
|
||||
trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
@@ -158,6 +159,31 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
$topology = json_decode($this->ReadPropertyString('AnlagenBatterien'), true, 64, JSON_THROW_ON_ERROR);
|
||||
$actual = [];$now = time();
|
||||
foreach ($topology as $asset) {
|
||||
$controllers=[];
|
||||
foreach ($this->aktiveVerbraucherIDs() as $controller) {
|
||||
if (IPS_InstanceExists($controller)
|
||||
&& IPS_GetInstance($controller)['ModuleInfo']['ModuleID']==='{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}'
|
||||
&& IPS_GetProperty($controller,'IstleistungVariableID')===($asset['LeistungVariableID']??null)
|
||||
&& IPS_GetProperty($controller,'LadezustandVariableID')===($asset['SOCVariableID']??null)) $controllers[]=$controller;
|
||||
}
|
||||
if (count($controllers)>1) throw new RuntimeException('feedback_controller_ambiguous');
|
||||
if (count($controllers)===1 && function_exists('ENELIX_GetV4BatterieRueckmeldung')) {
|
||||
$controller=$controllers[0];$configured=IPS_GetProperty($controller,'NetzfahrplanV4RueckmeldungKonfiguration');
|
||||
if (is_string($configured) && !in_array(trim($configured),['','{}'],true)) {
|
||||
if (count($topology)!==1) throw new RuntimeException('feedback_multi_asset_adapter_required');
|
||||
$f=json_decode(ENELIX_GetV4BatterieRueckmeldung($controller),true,32,JSON_THROW_ON_ERROR);
|
||||
if (($f['installationId']??null)!==$this->ReadAttributeString('LizenzInstallationID')
|
||||
|| ($f['assetId']??null)!==$asset['ID'] || ($f['managerId']??null)!==$this->InstanceID
|
||||
|| ($f['batteryInstanceId']??null)!==$controller || !is_numeric($f['batteryW']??null)
|
||||
|| !is_numeric($f['gridW']??null) || !is_int($f['checkedAt']??null) || $now-$f['checkedAt']>2 || $f['checkedAt']>$now) {
|
||||
throw new RuntimeException('feedback_invalid_no_legacy_fallback');
|
||||
}
|
||||
$actual[$asset['ID']]=$f['batteryW'];$d['operation']['gridW']=$f['gridW'];
|
||||
$d['operation']['localFeedback'][$asset['ID']]=$f;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Legacy value remains display-only until an explicit physical feedback mapping exists.
|
||||
$id = (int) ($asset['LeistungVariableID'] ?? 0);
|
||||
if (!IPS_VariableExists($id)) throw new RuntimeException('Batterie-Istleistung fehlt.');
|
||||
$before = IPS_GetVariable($id);$v = GetValue($id);$after = IPS_GetVariable($id);
|
||||
@@ -189,6 +215,8 @@ trait ManagerNetzfahrplanV4EmpfangTrait
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($envelope['response'],
|
||||
$this->ReadAttributeString('LizenzInstallationID'), $operation, time(), $envelope['receivedAt']);
|
||||
$out = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, time());
|
||||
$out['localFeedback'] = $operation['localFeedback'] ?? [];
|
||||
$out['feedbackMeaning'] = isset($operation['localFeedback']) ? 'fresh_physical_or_explicit_partition_model' : 'legacy_display_only_not_for_trial';
|
||||
$out['reception'] = json_decode($this->ReadAttributeString('NetzfahrplanV4EmpfangStatus'), true);
|
||||
} catch (Throwable $error) {
|
||||
$out = ['status' => 'unavailable', 'reason' => substr($error->getMessage(), 0, 300)];
|
||||
|
||||
@@ -78,6 +78,8 @@ trait ManagerNetzfahrplanV4TestTrait
|
||||
'expiresAt' => $until, 'maxChargeW' => NetzfahrplanV4Regeltest::number($request['maxChargeW'] ?? null, 'Testladegrenze', 1, 5000),
|
||||
'maxDischargeW' => NetzfahrplanV4Regeltest::number($request['maxDischargeW'] ?? null, 'Testentladegrenze', 1, 5000),
|
||||
'actuatorWatchdogEvidenceId' => IPS_GetProperty($batteryID, 'NetzfahrplanV4GeraeteWatchdogNachweis'),
|
||||
'feedbackConfigHash' => $op['localFeedback'][$a['assetId']]['configHash'] ?? '',
|
||||
'gridLimits' => NetzfahrplanV4Planpruefung::kontext($op)['limits'],
|
||||
'sequence' => 1, 'lastWallClockAt' => $now,
|
||||
'monotonicDeadline' => hrtime(true) / 1e9 + $until - $now];
|
||||
// Validate everything BEFORE arming either module. This rejects all normal shadow replies.
|
||||
@@ -156,6 +158,7 @@ trait ManagerNetzfahrplanV4TestTrait
|
||||
}
|
||||
if (!$found) throw new RuntimeException('Testbatterie nicht mehr in aktiver Zuordnung.');
|
||||
$op['gridW'] += $otherDelta;
|
||||
$op['localPredictedOtherDeltaW']=$otherDelta;
|
||||
if (!is_array($envelope['response'] ?? null) || !is_int($envelope['receivedAt'] ?? null)) {
|
||||
throw new RuntimeException('Kein gueltiger empfangener Testkontext.');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Belevo\EnelixEMS;
|
||||
use InvalidArgumentException;
|
||||
|
||||
require_once __DIR__ . '/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/NetzfahrplanV4Rueckmeldung.php';
|
||||
|
||||
/** Separate, explicitly authorized commissioning commands. A preview is NEVER permission. */
|
||||
final class NetzfahrplanV4Regeltest
|
||||
@@ -87,6 +88,11 @@ final class NetzfahrplanV4Regeltest
|
||||
if (($a['controlContext'] ?? null) != $plan['controlContext']) {
|
||||
throw new InvalidArgumentException('Testkonfiguration stimmt nicht mehr.');
|
||||
}
|
||||
$feedback = $operation['localFeedback'][$a['assetId']] ?? [];
|
||||
NetzfahrplanV4Rueckmeldung::requireTrial($feedback, $local, $now);
|
||||
if (abs(self::number($actual[$a['assetId']] ?? null,'Batterie Ist',-1e9,1e9) - self::number($feedback['batteryW'],'Rueckmeldung',-1e9,1e9)) > 0.000001) throw new InvalidArgumentException('feedback_power_not_from_verified_snapshot');
|
||||
$otherDelta=self::number($operation['localPredictedOtherDeltaW']??0.0,'Andere Verbraucherabweichung',-1e9,1e9);
|
||||
if (abs($operation['gridW']-$feedback['gridW']-$otherDelta)>0.001) throw new InvalidArgumentException('feedback_grid_not_from_same_snapshot');
|
||||
$preview = NetzfahrplanV4Planpruefung::vorschau($checked, $operation, $actual, $now);
|
||||
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, self::MAX_TEST_POWER_W),
|
||||
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, self::MAX_TEST_POWER_W));
|
||||
@@ -109,6 +115,9 @@ final class NetzfahrplanV4Regeltest
|
||||
'watts' => $watts, 'maxChargeW' => (int) $charge, 'maxDischargeW' => (int) $discharge,
|
||||
'sourceShadowPlanId' => $plan['planId'], 'sourceStep' => $p['time'],
|
||||
'revision' => $plan['configRevision'], 'expectedGridW' => $grid,
|
||||
'feedbackConfigHash' => $feedback['configHash'], 'feedbackEstimated' => $feedback['estimated'],
|
||||
'feedbackSourceOldestAt' => $feedback['sourceOldestAt'],
|
||||
'importLimitW' => $p['importLimitW'], 'exportLimitW' => $p['exportLimitW'],
|
||||
'executionMeaning' => 'separate_local_control_trial_not_shadow_plan_execution'];
|
||||
}
|
||||
|
||||
@@ -139,6 +148,23 @@ final class NetzfahrplanV4Regeltest
|
||||
if ($c > $session['maxChargeW'] || $d > $session['maxDischargeW'] || $w > $c || $w < -$d) {
|
||||
throw new InvalidArgumentException('Befehl ueberschreitet lokale Testgrenze.');
|
||||
}
|
||||
if (!is_string($command['feedbackConfigHash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $command['feedbackConfigHash'])
|
||||
|| $command['feedbackConfigHash'] !== ($session['feedbackConfigHash'] ?? null)) throw new InvalidArgumentException('feedback_command_binding_invalid');
|
||||
foreach (['importLimitW','exportLimitW'] as $limit) {
|
||||
if (!array_key_exists($limit,$command)) throw new InvalidArgumentException('feedback_command_limits_missing');
|
||||
if ($command[$limit] !== null) self::number($command[$limit],$limit,0,1e9);
|
||||
}
|
||||
$l=$session['gridLimits'] ?? null;
|
||||
if (!is_array($l) || !array_key_exists('importW',$l) || !array_key_exists('exportW',$l) || !is_array($l['managerMonthLimitsW']??null)) throw new InvalidArgumentException('session_grid_limits_missing');
|
||||
$month=(int)(new \DateTimeImmutable('@'.$now))->setTimezone(new \DateTimeZone('Europe/Zurich'))->format('n');
|
||||
$import=$l['importW'];$monthly=$l['managerMonthLimitsW'][$month]??null;
|
||||
if ($monthly!==null) $import=$import===null?$monthly:min($import,$monthly);
|
||||
foreach (['importLimitW'=>$import,'exportLimitW'=>$l['exportW']] as $key=>$limit) {
|
||||
if ($limit!==null) {
|
||||
self::number($limit,$key,0,1e9);
|
||||
if ($command[$key]===null || $command[$key]>$limit) throw new InvalidArgumentException('command_relaxes_local_grid_limit');
|
||||
}
|
||||
}
|
||||
self::uuid($command['sourceShadowPlanId'] ?? null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Fresh physical feedback. Pure reader: no writes, history reconstruction or actuator authority. */
|
||||
final class NetzfahrplanV4Rueckmeldung
|
||||
{
|
||||
private static function number($v, float $lo, float $hi): float
|
||||
{
|
||||
if ((!is_int($v) && !is_float($v)) || !is_finite((float)$v) || $v<$lo || $v>$hi) {
|
||||
throw new InvalidArgumentException('feedback_numeric_invalid');
|
||||
}
|
||||
return (float)$v;
|
||||
}
|
||||
|
||||
private static function ordered(array $v): array
|
||||
{
|
||||
if (!array_is_list($v)) ksort($v, SORT_STRING);
|
||||
foreach ($v as &$x) if (is_array($x)) $x=self::ordered($x);
|
||||
unset($x);return $v;
|
||||
}
|
||||
|
||||
public static function configuration(array $c): array
|
||||
{
|
||||
$keys=['version','installationId','assetId','managerId','batteryInstanceId','mode','allowEstimatedForTrial',
|
||||
'maxSkewSeconds','maxAgeSeconds','idleToleranceW','trackingToleranceW','sources'];
|
||||
if (count($c)!==count($keys) || array_diff($keys,array_keys($c)) || $c['version']!==1
|
||||
|| !is_string($c['installationId']) || !preg_match('/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/D',$c['installationId'])
|
||||
|| !is_string($c['assetId']) || $c['assetId']==='' || strlen($c['assetId'])>100
|
||||
|| !in_array($c['mode'],['physical_sum','virtual_split'],true) || !is_bool($c['allowEstimatedForTrial'])) {
|
||||
throw new InvalidArgumentException('feedback_configuration_invalid');
|
||||
}
|
||||
foreach (['managerId','batteryInstanceId'] as $k) if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>99999) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||
if ($c['managerId']===$c['batteryInstanceId']) throw new InvalidArgumentException('feedback_binding_invalid');
|
||||
foreach (['maxSkewSeconds'=>30,'maxAgeSeconds'=>60] as $k=>$max) {
|
||||
if (!is_int($c[$k]) || $c[$k]<1 || $c[$k]>$max) throw new InvalidArgumentException('feedback_freshness_invalid');
|
||||
}
|
||||
$c['idleToleranceW']=self::number($c['idleToleranceW'],0,100);
|
||||
$c['trackingToleranceW']=self::number($c['trackingToleranceW'],0,500);
|
||||
if (!is_array($c['sources']) || !array_is_list($c['sources']) || count($c['sources'])<2 || count($c['sources'])>40) throw new InvalidArgumentException('feedback_sources_invalid');
|
||||
$ids=[];$names=[];$roles=[];
|
||||
foreach ($c['sources'] as &$s) {
|
||||
$fields=['key','role','variableId','parentId','ident','factorToW'];
|
||||
if (!is_array($s) || count($s)!==count($fields) || array_diff($fields,array_keys($s))
|
||||
|| !is_string($s['key']) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/D',$s['key'])
|
||||
|| isset($names[$s['key']]) || !in_array($s['role'],['grid','physical','ev_request','sdl_request','gateway_active'],true)
|
||||
|| !is_int($s['variableId']) || $s['variableId']<1 || $s['variableId']>99999 || isset($ids[$s['variableId']])
|
||||
|| !is_int($s['parentId']) || $s['parentId']<1 || !is_string($s['ident']) || strlen($s['ident'])>100) throw new InvalidArgumentException('feedback_source_identity_invalid');
|
||||
$s['factorToW']=self::number($s['factorToW'],-1e6,1e6);
|
||||
if ($s['factorToW']==0 || ($s['role']==='gateway_active' && $s['factorToW']!==1.0)) throw new InvalidArgumentException('feedback_factor_invalid');
|
||||
$ids[$s['variableId']]=true;$names[$s['key']]=true;$roles[$s['role']]=($roles[$s['role']]??0)+1;
|
||||
}
|
||||
unset($s);
|
||||
if (($roles['grid']??0)!==1 || ($roles['physical']??0)<1) throw new InvalidArgumentException('feedback_measurements_required');
|
||||
foreach (['ev_request','sdl_request','gateway_active'] as $role) {
|
||||
if (($roles[$role]??0)!==($c['mode']==='virtual_split'?1:0)) throw new InvalidArgumentException('feedback_partition_sources_required');
|
||||
}
|
||||
usort($c['sources'],static fn($a,$b)=>strcmp($a['key'],$b['key']));
|
||||
return self::ordered($c);
|
||||
}
|
||||
|
||||
public static function fingerprint(array $c): string
|
||||
{
|
||||
return hash('sha256',json_encode(self::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
}
|
||||
|
||||
/** Reader supplies value, original updated/changed, parentID, ident. Never trusts collector timestamp alone. */
|
||||
public static function read(array $config, callable $reader, int $now): array
|
||||
{
|
||||
$c=self::configuration($config);
|
||||
if ($now<1) throw new InvalidArgumentException('feedback_time_invalid');
|
||||
$first=[];$values=[];$times=[];$physicalTimes=[];$changed=[];
|
||||
foreach ($c['sources'] as $s) {
|
||||
$r=$reader($s['variableId']);
|
||||
if (!is_array($r) || ($r['parentID']??null)!==$s['parentId'] || ($r['ident']??null)!==$s['ident']
|
||||
|| !is_int($r['updated']??null) || $r['updated']<1 || $r['updated']>$now
|
||||
|| !is_int($r['changed']??null) || $r['changed']<0 || $r['changed']>$r['updated']) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
|
||||
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident']));
|
||||
if ($s['role']==='gateway_active') {
|
||||
if (($r['value']??null)!==true) throw new InvalidArgumentException('feedback_gateway_inactive');
|
||||
} else {
|
||||
$w=self::number($r['value']??null,-1e9,1e9)*$s['factorToW'];
|
||||
$values[$s['role']]=($values[$s['role']]??0)+self::number($w,-1e9,1e9);
|
||||
}
|
||||
// An unchanged command is held STATE, not proof of a fresh sensor or an actuator watchdog.
|
||||
if (in_array($s['role'],['grid','physical'],true)) {
|
||||
if ($now-$r['updated']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
|
||||
$times[]=$r['updated'];if ($s['role']==='physical') $physicalTimes[]=$r['updated'];
|
||||
} else $changed[$s['role']]=$r['changed'];
|
||||
}
|
||||
foreach ($c['sources'] as $s) {
|
||||
$second=$reader($s['variableId']);
|
||||
if (!is_array($second) || array_intersect_key($second,array_flip(['value','updated','changed','parentID','ident']))!==$first[$s['key']]) {
|
||||
throw new InvalidArgumentException('feedback_changed_during_read:'.$s['key']);
|
||||
}
|
||||
}
|
||||
if (max($times)-min($times)>$c['maxSkewSeconds']) throw new InvalidArgumentException('feedback_source_skew');
|
||||
$physical=(float)$values['physical'];$power=$physical;$external=0.0;$estimated=$c['mode']==='virtual_split';$reasons=[];$tracking=null;
|
||||
if ($estimated) {
|
||||
if ($changed['gateway_active']>min($physicalTimes) || $changed['sdl_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_external_transition_not_observed');
|
||||
$ev=(float)$values['ev_request'];$sdl=(float)$values['sdl_request'];$tracking=$physical-$ev-$sdl;
|
||||
if ($sdl===0.0) {
|
||||
// No external commanded contribution. Physical response may lag a NEW EV command.
|
||||
$power=$physical;
|
||||
if ($ev===0.0 && abs($physical)>$c['idleToleranceW']) throw new InvalidArgumentException('feedback_unassigned_idle_power');
|
||||
} else {
|
||||
if ($changed['ev_request']>min($physicalTimes)) throw new InvalidArgumentException('feedback_ev_transition_not_observed');
|
||||
if (abs($tracking)>$c['trackingToleranceW']) throw new InvalidArgumentException('feedback_partition_not_tracking');
|
||||
$power=$ev+$tracking*abs($ev)/(abs($ev)+abs($sdl));$external=$physical-$power;
|
||||
if ($ev*$sdl<0) $reasons[]='opposing_virtual_flows_not_identifiable';
|
||||
}
|
||||
if (!$c['allowEstimatedForTrial']) $reasons[]='modelled_feedback_not_accepted_for_trial';
|
||||
}
|
||||
return ['version'=>1,'kind'=>'local_physical_feedback','installationId'=>$c['installationId'],'assetId'=>$c['assetId'],
|
||||
'managerId'=>$c['managerId'],'batteryInstanceId'=>$c['batteryInstanceId'],'checkedAt'=>$now,
|
||||
'sourceOldestAt'=>min($times),'sourceNewestAt'=>max($times),'physicalOldestAt'=>min($physicalTimes),
|
||||
'sourceSkewSeconds'=>max($times)-min($times),'maxAgeSeconds'=>$c['maxAgeSeconds'],
|
||||
'configHash'=>self::fingerprint($c),'gridW'=>(float)$values['grid'],'physicalStorageW'=>$physical,
|
||||
'batteryW'=>$power,'externalModelW'=>$external,'trackingErrorW'=>$tracking,
|
||||
'estimated'=>$estimated,'separatelyMeasured'=>!$estimated,'usableForTrial'=>$reasons===[],
|
||||
'reasons'=>$reasons,'method'=>$estimated?'physical_request_partition_unfiltered':'physical_sum',
|
||||
'canDispatch'=>false,'gatewayWatchdogVerified'=>false,'heldFilterUsed'=>false];
|
||||
}
|
||||
|
||||
public static function requireTrial(array $f, array $binding, int $now): void
|
||||
{
|
||||
foreach (['installationId','assetId','managerId','batteryInstanceId'] as $key) {
|
||||
if (!array_key_exists($key,$binding) || ($f[$key]??null)!==$binding[$key]) throw new InvalidArgumentException('feedback_wrong_binding');
|
||||
}
|
||||
if (($f['kind']??null)!=='local_physical_feedback' || ($f['version']??null)!==1 || ($f['usableForTrial']??null)!==true
|
||||
|| !is_int($f['checkedAt']??null) || $f['checkedAt']>$now || $now-$f['checkedAt']>2
|
||||
|| !is_int($f['sourceOldestAt']??null) || !is_int($f['maxAgeSeconds']??null)
|
||||
|| $f['maxAgeSeconds']<1 || $f['maxAgeSeconds']>60 || $f['sourceOldestAt']>$now || $now-$f['sourceOldestAt']>$f['maxAgeSeconds']
|
||||
|| !is_string($f['configHash']??null) || !preg_match('/^[a-f0-9]{64}$/D',$f['configHash'])) throw new InvalidArgumentException('feedback_not_usable_for_trial');
|
||||
if (isset($binding['feedbackConfigHash']) && $binding['feedbackConfigHash']!==$f['configHash']) throw new InvalidArgumentException('feedback_configuration_changed');
|
||||
self::number($f['batteryW']??null,-1e9,1e9);self::number($f['gridW']??null,-1e9,1e9);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user