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.
164 lines
8.7 KiB
PHP
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];
|
|
}
|
|
}
|