Files
Enelix-EMS/libs/NetzfahrplanV4Regelung.php
T
dh 74a2d6c685
Tests / test (push) Successful in 1m1s
feat(workflow): publish verified forecast controls and consolidated docs
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.
2026-10-08 10:02:58 +00:00

230 lines
12 KiB
PHP

<?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,
]];
}
}