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,163 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user