Files
Enelix-EMS/libs/NetzfahrplanV4Rueckmeldung.php
T

148 lines
10 KiB
PHP

<?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=[];$variableTimes=[];$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']
|| (in_array($s['role'],['grid','physical'],true)
&& (!is_int($r['confirmedAt']??null) || $r['confirmedAt']<1 || $r['confirmedAt']>$now
|| $r['updated']>$r['confirmedAt']))) throw new InvalidArgumentException('feedback_origin_invalid:'.$s['key']);
$first[$s['key']]=array_intersect_key($r,array_flip(['value','updated','changed','parentID','ident','confirmedAt']));
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);
}
// VariableUpdated is retained as provenance. Freshness comes only from a successful device response.
if (in_array($s['role'],['grid','physical'],true)) {
if ($now-$r['confirmedAt']>$c['maxAgeSeconds']) throw new InvalidArgumentException('feedback_stale_physical:'.$s['key']);
$times[]=$r['confirmedAt'];$variableTimes[]=$r['updated'];
if ($s['role']==='physical') $physicalTimes[]=$r['confirmedAt'];
} 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','confirmedAt']))!==$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),'sourceVariableOldestAt'=>min($variableTimes),
'sourceVariableNewestAt'=>max($variableTimes),'deviceReadConfirmed'=>true,'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
|| ($f['deviceReadConfirmed']??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);
}
}