feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s
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.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
<?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,
|
||||
]];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user