Tests / test (push) Successful in 1m1s
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.
230 lines
12 KiB
PHP
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,
|
|
]];
|
|
}
|
|
}
|