feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission. Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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
|
||||
{
|
||||
public const MAX_TEST_SECONDS = 1800;
|
||||
public const MAX_ACTIVE_TEST_SECONDS = 172800;
|
||||
public const COMMAND_TTL_SECONDS = 10;
|
||||
|
||||
public static function number($value, string $name, float $min, float $max): float
|
||||
{
|
||||
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value) || $value < $min || $value > $max) {
|
||||
throw new InvalidArgumentException($name . ': ungueltiger Wert.');
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
public static function uuid($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $value)) {
|
||||
throw new InvalidArgumentException('Eindeutige Testkennung erforderlich.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function integer($value, string $name, int $min, int $max): int
|
||||
{
|
||||
if (!is_int($value) || $value < $min || $value > $max) {
|
||||
throw new InvalidArgumentException($name . ': Ganzzahl erforderlich.');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** Validated server authorization plus independent LOCAL consent and physical mapping. */
|
||||
public static function befehl(array $response, array $operation, array $actual, array $local, int $now, int $receivedAt): array
|
||||
{
|
||||
if (($local['enabled'] ?? null) !== true || ($local['legacyEnabled'] ?? null) !== false) {
|
||||
throw new InvalidArgumentException('Keine lokale Testfreigabe oder konkurrierender Fahrplan.');
|
||||
}
|
||||
$installation = self::uuid($local['installationId'] ?? null);
|
||||
$session = self::uuid($local['sessionId'] ?? null);
|
||||
$manager = self::integer($local['managerId'] ?? null, 'Manager', 1, 99999);
|
||||
$controller = self::integer($local['batteryInstanceId'] ?? null, 'Batterieinstanz', 1, 99999);
|
||||
$activeTest = ($local['kind'] ?? null) === 'local_active_test_consent';
|
||||
$maxSeconds = $activeTest ? self::MAX_ACTIVE_TEST_SECONDS : self::MAX_TEST_SECONDS;
|
||||
$end = self::integer($local['expiresAt'] ?? null, 'Lokales Testende', $now + 1, $now + $maxSeconds);
|
||||
if (($local['lastWallClockAt'] ?? $now) > $now) throw new InvalidArgumentException('Systemzeit rueckwaerts; Test abbrechen.');
|
||||
$checked = NetzfahrplanV4Planpruefung::pruefen($response, $installation, $operation, $now, $receivedAt);
|
||||
$plan = $checked['plan'];$a = $response['controlledTrial'] ?? null;
|
||||
if (!is_array($a) || ($a['version'] ?? null) !== 1 || ($a['kind'] ?? null) !== 'controlled_trial_authority'
|
||||
|| ($a['sourcePlanRemainsShadow'] ?? null) !== true || ($a['installationId'] ?? null) !== $installation
|
||||
|| ($a['sessionId'] ?? null) !== $session || ($a['managerId'] ?? null) !== $manager
|
||||
|| ($a['batteryInstanceId'] ?? null) !== $controller || ($a['sourceShadowPlanId'] ?? null) !== $plan['planId']
|
||||
|| ($a['revision'] ?? null) !== $plan['configRevision'] || ($a['family'] ?? null) !== $plan['sourceFamily']) {
|
||||
throw new InvalidArgumentException('Keine passende separate Server-Testfreigabe.');
|
||||
}
|
||||
$issued = NetzfahrplanV4Planpruefung::zeit($a['issuedAt'] ?? null);
|
||||
$expires = NetzfahrplanV4Planpruefung::zeit($a['expiresAt'] ?? null);
|
||||
$authorityAt = NetzfahrplanV4Planpruefung::zeit($a['checkedAt'] ?? null);
|
||||
$authorityUntil = NetzfahrplanV4Planpruefung::zeit($a['validUntil'] ?? null);
|
||||
if ($issued > $now || $expires <= $now || $expires - $issued > $maxSeconds
|
||||
|| $end > $expires || $authorityAt > $now + 2 || $authorityUntil <= $now
|
||||
|| $authorityUntil - $authorityAt > 90 || $authorityUntil > $expires) {
|
||||
throw new InvalidArgumentException('Testfreigabe abgelaufen oder ungueltig.');
|
||||
}
|
||||
$quality = $plan['inputQuality'] ?? [];
|
||||
$evidence = $quality['accountingEvidenceId'] ?? null;
|
||||
$watchdogBound = is_string($a['actuatorWatchdogEvidenceId'] ?? null)
|
||||
&& strlen($a['actuatorWatchdogEvidenceId']) >= 8
|
||||
&& $a['actuatorWatchdogEvidenceId'] === ($local['actuatorWatchdogEvidenceId'] ?? null);
|
||||
$watchdogWaived = $activeTest && ($local['watchdogWaived'] ?? null) === true
|
||||
&& ($a['actuatorWatchdogEvidenceId'] ?? null) === 'explicit_test_plant_watchdog_waiver';
|
||||
if (($quality['loadBasis'] ?? null) !== 'base_load' || !is_string($evidence) || strlen($evidence) < 8
|
||||
|| $evidence !== ($a['accountingEvidenceId'] ?? null)
|
||||
|| (!$watchdogBound && !$watchdogWaived)) {
|
||||
throw new InvalidArgumentException('Anlagenbilanz oder Geraete-Watchdog nicht nachgewiesen.');
|
||||
}
|
||||
if (($plan['peakCostIsEstimate'] ?? true) && ($a['acceptEstimatedPeak'] ?? null) !== true) {
|
||||
throw new InvalidArgumentException('Geschaetzte Peakbasis nicht fuer Test akzeptiert.');
|
||||
}
|
||||
if (count($operation['batteries']) !== 1 || $operation['batteries'][0]['id'] !== ($a['assetId'] ?? null)
|
||||
|| ($local['assetId'] ?? null) !== $a['assetId']) {
|
||||
throw new InvalidArgumentException('Test braucht eindeutige Batteriezuordnung.');
|
||||
}
|
||||
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);
|
||||
$battery = $operation['batteries'][0];
|
||||
$charge = min(self::number($a['maxChargeW'] ?? null, 'Server-Ladegrenze', 1, 1e9),
|
||||
self::number($local['maxChargeW'] ?? null, 'Lokale Ladegrenze', 1, 1e9),
|
||||
self::number($battery['maxChargeW'] ?? null, 'Anlagen-Ladegrenze', 1, 1e9));
|
||||
$discharge = min(self::number($a['maxDischargeW'] ?? null, 'Server-Entladegrenze', 1, 1e9),
|
||||
self::number($local['maxDischargeW'] ?? null, 'Lokale Entladegrenze', 1, 1e9),
|
||||
self::number($battery['maxDischargeW'] ?? null, 'Anlagen-Entladegrenze', 1, 1e9));
|
||||
$watts = (int) (max(-$discharge, min($charge, $preview['previewBatteryW']))); // truncate towards zero
|
||||
$residual = $preview['actualGridW'] - $preview['actualBatteryW'];
|
||||
$grid = $residual + $watts;
|
||||
$p = $checked['point'];
|
||||
if (($p['importLimitW'] !== null && $grid > $p['importLimitW'] + 1)
|
||||
|| ($p['exportLimitW'] !== null && -$grid > $p['exportLimitW'] + 1)) {
|
||||
throw new InvalidArgumentException('Testleistungsgrenze reicht fuer Netzgrenze nicht; lokale Regelung hat Vorrang.');
|
||||
}
|
||||
return ['kind' => 'controlled_trial_command', 'version' => 1,
|
||||
'sessionId' => $session, 'installationId' => $installation, 'assetId' => $a['assetId'],
|
||||
'managerId' => $manager, 'batteryInstanceId' => $controller,
|
||||
'sequence' => self::integer($local['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX),
|
||||
'issuedAt' => $now, 'expiresAt' => min($now + self::COMMAND_TTL_SECONDS, $authorityUntil, $end,
|
||||
NetzfahrplanV4Planpruefung::zeit($p['validUntil'])),
|
||||
'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' => $activeTest
|
||||
? 'explicit_time_boxed_test_plant_operation'
|
||||
: 'separate_local_control_trial_not_shadow_plan_execution'];
|
||||
}
|
||||
|
||||
/** Independently checked by the battery before any physical register call. */
|
||||
public static function pruefeBatteriebefehl(array $command, array $session, int $now, float $monotonicNow): void
|
||||
{
|
||||
if (($command['kind'] ?? null) !== 'controlled_trial_command' || ($command['version'] ?? null) !== 1) {
|
||||
throw new InvalidArgumentException('Vorschau ist kein Stellbefehl.');
|
||||
}
|
||||
foreach (['sessionId', 'installationId', 'assetId', 'managerId', 'batteryInstanceId'] as $key) {
|
||||
if (!array_key_exists($key, $session) || ($command[$key] ?? null) !== $session[$key]) {
|
||||
throw new InvalidArgumentException('Befehl gehoert nicht zur lokal angemeldeten Testsitzung.');
|
||||
}
|
||||
}
|
||||
if (($session['active'] ?? null) !== true || $now >= ($session['expiresAt'] ?? 0)
|
||||
|| $monotonicNow >= ($session['monotonicDeadline'] ?? 0)
|
||||
|| $now < ($session['lastWallClockAt'] ?? $now)) {
|
||||
throw new InvalidArgumentException('Lokale Testsitzung abgelaufen/abgebrochen.');
|
||||
}
|
||||
$sent = self::integer($command['issuedAt'] ?? null, 'Befehlszeit', $now - self::COMMAND_TTL_SECONDS, $now);
|
||||
$end = self::integer($command['expiresAt'] ?? null, 'Befehlsende', $now + 1, $sent + self::COMMAND_TTL_SECONDS);
|
||||
if ($end > $session['expiresAt']) throw new InvalidArgumentException('Befehl verlaengert Test.');
|
||||
$seq = self::integer($command['sequence'] ?? null, 'Befehlsfolge', 1, PHP_INT_MAX);
|
||||
if ($seq <= ($session['lastSequence'] ?? 0)) throw new InvalidArgumentException('Alter/doppelter Befehl verworfen.');
|
||||
$sessionCharge = self::number($session['maxChargeW'] ?? null, 'Sitzungs-Ladegrenze', 1, 1e9);
|
||||
$sessionDischarge = self::number($session['maxDischargeW'] ?? null, 'Sitzungs-Entladegrenze', 1, 1e9);
|
||||
$w = self::integer($command['watts'] ?? null, 'Sollwert W', -(int) $sessionDischarge, (int) $sessionCharge);
|
||||
$c = self::number($command['maxChargeW'] ?? null, 'Befehl-Ladegrenze', 1, $sessionCharge);
|
||||
$d = self::number($command['maxDischargeW'] ?? null, 'Befehl-Entladegrenze', 1, $sessionDischarge);
|
||||
if ($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);
|
||||
}
|
||||
|
||||
/** Re-evaluated on every battery update; no reliance on the manager's earlier clipping. */
|
||||
public static function batterieZiel(array $command, array $session, array $m, array $policy, int $now, float $monotonicNow): int
|
||||
{
|
||||
if ($now >= $command['expiresAt'] || $now < $session['lastWallClockAt']
|
||||
|| $monotonicNow >= $session['commandMonotonicDeadline'] || $monotonicNow >= $session['monotonicDeadline']) {
|
||||
throw new InvalidArgumentException('Befehls-Watchdog abgelaufen.');
|
||||
}
|
||||
if (($m['Gueltig'] ?? null) !== true || ($policy['available'] ?? null) !== true) {
|
||||
throw new InvalidArgumentException('Batterie nicht verfuegbar oder Messwerte ungueltig.');
|
||||
}
|
||||
$soc = self::number($m['Ladezustand'] ?? null, 'SOC', 0, 100);
|
||||
$reserve = self::number($policy['reserve'] ?? null, 'Reserve', 0, 100);
|
||||
$minimum = self::number($policy['minimum'] ?? null, 'Minimum', 0, $reserve);
|
||||
if ($soc < $minimum) throw new InvalidArgumentException('SOC unter technischer Untergrenze.');
|
||||
$charge = min($session['maxChargeW'], $command['maxChargeW'], self::number($m['MaxLaden'] ?? null, 'Max Laden', 0, 1e9));
|
||||
$discharge = min($session['maxDischargeW'], $command['maxDischargeW'], self::number($m['MaxEntladen'] ?? null, 'Max Entladen', 0, 1e9));
|
||||
if ($soc >= 99) $charge = 0; // existing battery hardware policy is more conservative than planner max 100
|
||||
if ($soc <= $reserve || ($policy['blocked'] ?? true)) $discharge = 0;
|
||||
if (($soc <= $reserve || ($policy['blocked'] ?? true)) && $policy['rechargeLimitW'] !== null) {
|
||||
$charge = min($charge, self::number($policy['rechargeLimitW'], 'Nachladegrenze', 0, 1e9));
|
||||
}
|
||||
return (int) max(-$discharge, min($charge, $command['watts']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user