Files
Enelix-EMS/libs/NetzfahrplanV4Einrichtung.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

164 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixEMS;
use InvalidArgumentException;
use Throwable;
require_once __DIR__ . '/NetzfahrplanV4Messaufnahme.php';
/** Pure defaults for ordinary, separately metered AC plants. No activation or I/O.
* Existing specialist mappings must be retained by the caller, not passed here.
*/
final class NetzfahrplanV4Einrichtung
{
private static function identifier($value, string $label): string
{
if (!is_string($value) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', trim($value))) {
throw new InvalidArgumentException($label . ': eindeutige Anlagenkennung fehlt.');
}
return trim($value);
}
private static function objectId($value, string $label): int
{
if (!is_int($value) || $value < 1 || $value > 99999) {
throw new InvalidArgumentException($label . ': gueltige Objektzuordnung fehlt.');
}
return $value;
}
private static function factor($value): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float) $value)
|| $value == 0 || abs($value) > 1.0e6) {
throw new InvalidArgumentException('Expliziter Leistungsfaktor fehlt oder ist ungueltig.');
}
return (float) $value;
}
private static function source(string $key, string $role, $id, $factor, int $age, callable $read): array
{
$id = self::objectId($id, $role);
try {
$object = $read($id);
} catch (Throwable $e) {
// Reader exceptions may contain connection settings; never propagate them.
throw new InvalidArgumentException('Messquelle ' . $id . ' ist nicht verfuegbar.');
}
if (!is_array($object) || !in_array($object['type'] ?? null, [1, 2], true)
|| !is_int($object['parentID'] ?? null) || $object['parentID'] < 1 || $object['parentID'] > 99999
|| !is_string($object['ident'] ?? null) || strlen($object['ident']) > 100) {
throw new InvalidArgumentException('Messquelle ' . $id . ' ist keine eindeutig zugeordnete numerische Variable.');
}
if (($object['virtual'] ?? false) !== false || ($object['sharedStorage'] ?? false) !== false) {
throw new InvalidArgumentException('Virtuelle oder geteilte Speicher benoetigen eine explizite Messzuordnung.');
}
return ['key' => $key, 'role' => $role, 'variableId' => $id,
'parentId' => $object['parentID'], 'ident' => $object['ident'],
'factorToW' => self::factor($factor), 'maxAgeSeconds' => $age, 'dependsOn' => []];
}
/** objectReader(int): {parentID:int, ident:string, type:int, virtual?:bool, sharedStorage?:bool}.
* type is IPS VariableType (1=int, 2=float), not ObjectType. Readers verify
* the object really is a variable and reject derived/virtual source parents.
* No values, timestamps, full instance settings or credentials are needed.
*
* @return array{capture:array,dataset:array,datasetId:string,mappingSha256:string,inventorySha256:string}
*/
public static function erstellen(
string $installationId,
int $managerId,
array $manager,
array $inverters,
array $batteries,
callable $objectReader
): array {
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/D', $installationId)) {
throw new InvalidArgumentException('Gueltige Installationskennung erforderlich.');
}
self::objectId($managerId, 'Manager');
$age = $manager['MesswertMaxAlter'] ?? null;
if (!is_int($age) || $age < 1) {
throw new InvalidArgumentException('Maximales Messwertalter fehlt.');
}
$age = min(60, $age);
if (($manager['SDLAktiv'] ?? false) !== false || ($manager['SDLLeistungVariableID'] ?? 0) !== 0) {
throw new InvalidArgumentException('SDL-Anlage benoetigt eine explizite physische Messzuordnung.');
}
if (array_keys($inverters) !== array_keys(array_values($inverters))
|| array_keys($batteries) !== array_keys(array_values($batteries))
|| $inverters === [] || $batteries === [] || count($inverters) + count($batteries) > 79) {
throw new InvalidArgumentException('Vollstaendige Wechselrichter- und Batterietopologie erforderlich.');
}
$sources = [self::source('grid', 'grid', $manager['NetzleistungVariableID'] ?? null,
$manager['Netzleistungsfaktor'] ?? null, $age, $objectReader)];
$inverterMap = [];
foreach ($inverters as $inverter) {
if (!is_array($inverter)) {
throw new InvalidArgumentException('Ungueltiger Wechselrichtereintrag.');
}
$id = self::identifier($inverter['ID'] ?? null, 'Wechselrichter');
if (isset($inverterMap[$id]) || !in_array($inverter['Typ'] ?? null, ['pv', 'battery'], true)) {
throw new InvalidArgumentException('Doppelte oder hybride Wechselrichter benoetigen eine explizite Messzuordnung.');
}
$inverterMap[$id] = $inverter['Typ'];
if ($inverter['Typ'] === 'pv') {
$sources[] = self::source('pv_' . substr(hash('sha256', $id), 0, 24), 'pv',
$inverter['IstleistungVariableID'] ?? null, $inverter['Istleistungsfaktor'] ?? null, $age, $objectReader);
}
}
$batteryMap = [];
$usedInverters = [];
foreach ($batteries as $battery) {
if (!is_array($battery)) {
throw new InvalidArgumentException('Ungueltiger Batterieeintrag.');
}
$id = self::identifier($battery['ID'] ?? null, 'Batterie');
$inverterId = self::identifier($battery['WechselrichterID'] ?? null, 'Batteriewechselrichter');
if (isset($batteryMap[$id]) || ($battery['Kopplung'] ?? null) !== 'ac'
|| ($inverterMap[$inverterId] ?? null) !== 'battery' || isset($usedInverters[$inverterId])) {
throw new InvalidArgumentException('Batterie benoetigt einen eigenen AC-Wandler und eine eindeutige Leistungsmessung.');
}
if (($battery['Virtuell'] ?? false) !== false || ($battery['SDLAktiv'] ?? false) !== false) {
throw new InvalidArgumentException('Virtuelle oder SDL-Batterie benoetigt eine explizite Messzuordnung.');
}
$batteryMap[$id] = $inverterId;
$usedInverters[$inverterId] = true;
$sources[] = self::source('battery_' . substr(hash('sha256', $id), 0, 24), 'physical_storage',
$battery['LeistungVariableID'] ?? null, $battery['Leistungsfaktor'] ?? null, $age, $objectReader);
}
foreach ($inverterMap as $id => $type) {
if ($type === 'battery' && !isset($usedInverters[$id])) {
throw new InvalidArgumentException('Nicht zugeordneter Batteriewechselrichter in der Anlagenbilanz.');
}
}
$accounting = NetzfahrplanV4Bilanzierung::konfigurieren([
'version' => 1, 'sources' => $sources, 'maxSkewSeconds' => min(30, $age),
'splitToleranceW' => 0.0, 'storageMode' => 'physical_only',
]);
ksort($inverterMap);
ksort($batteryMap);
$inventory = ['version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
'inverters' => $inverterMap, 'batteries' => $batteryMap, 'sources' => $accounting['sources']];
$inventoryHash = hash('sha256', json_encode($inventory, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
$capture = NetzfahrplanV4Messaufnahme::configuration([
'version' => 1, 'installationId' => $installationId, 'managerId' => $managerId,
'reportedInventorySha256' => $inventoryHash, 'accounting' => $accounting, 'extraSources' => [],
]);
$mappingHash = hash('sha256', json_encode($capture, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION));
$datasetId = 'v4-' . substr($mappingHash, 0, 32);
$dataset = ['datasetId' => $datasetId, 'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash,
'sources' => array_map(static fn(array $s): array => [
'key' => $s['key'], 'variableId' => $s['variableId'], 'role' => $s['role'],
'factorToW' => $s['factorToW'], 'maxAgeSeconds' => $s['maxAgeSeconds'],
], $capture['accounting']['sources']),
'formula' => 'physical_sum_v1', 'solarReference' => null, 'minimumCoverage' => 0.95,
'maximumGapSeconds' => 5, 'minimumTrainingHours' => 24, 'historyDays' => 30];
return ['capture' => $capture, 'dataset' => $dataset, 'datasetId' => $datasetId,
'mappingSha256' => $mappingHash, 'inventorySha256' => $inventoryHash];
}
}