$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); } }