Add opt-in SDL measurement sources and independent visualization toggles. Exclude balancing power from household telemetry and new household energy. Preserve custom views and cover measurement, migration and Symcon 8.0 behavior.
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Manager;
|
||||
use ManagerSDLRuntime;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @runTestsInSeparateProcesses
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
final class ManagerSDLTest extends TestCase
|
||||
{
|
||||
private Manager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
require_once __DIR__ . '/Support/ManagerSDLRuntime.php';
|
||||
require_once __DIR__ . '/../Manager/module.php';
|
||||
|
||||
ManagerSDLRuntime::reset();
|
||||
$this->manager = new Manager(42);
|
||||
$this->manager->Create();
|
||||
foreach ([101 => 1000.0, 102 => 5000.0, 103 => 200.0, 104 => 1500.0, 105 => 70.0] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
$this->properties([
|
||||
'NetzleistungVariableID' => 101,
|
||||
'AnlagenWechselrichter' => $this->json([
|
||||
['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102],
|
||||
]),
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'battery', 'LeistungVariableID' => 103],
|
||||
]),
|
||||
'SDLLeistungVariableID' => 104,
|
||||
'SDLSOCVariableID' => 105,
|
||||
]);
|
||||
foreach ([
|
||||
'SDLLeistungArchiv', 'SDLSOCArchiv', 'SDLLadenEnergie', 'SDLEntladenEnergie',
|
||||
] as $ident) {
|
||||
ManagerSDLRuntime::child(42, $ident);
|
||||
}
|
||||
ManagerSDLRuntime::child(42, 'SDLStatus', 3);
|
||||
}
|
||||
|
||||
public function testSDLByDefaultInactiveDoesNotRequireSources(): void
|
||||
{
|
||||
self::assertFalse($this->manager->testProperties['SDLAktiv']);
|
||||
self::assertFalse($this->manager->testProperties['SDLEnergieflussAnzeigen']);
|
||||
self::assertFalse($this->manager->testProperties['SDLDiagrammeAnzeigen']);
|
||||
unset(ManagerSDLRuntime::$variables[104], ManagerSDLRuntime::$variables[105]);
|
||||
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
$powers = $this->invoke('leseMessleistungen');
|
||||
|
||||
self::assertSame(0.0, $powers['SDL']);
|
||||
self::assertSame(5800.0, $powers['Haus']);
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
}
|
||||
|
||||
/** @dataProvider signedPowerProvider */
|
||||
public function testSDLPowerIsExcludedFromHouseConsumption(float $raw, float $factor, float $sdl, float $house): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]);
|
||||
ManagerSDLRuntime::variable(104, $raw);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
|
||||
self::assertSame([
|
||||
'PV' => 5000.0, 'Haus' => $house, 'Netz' => 1000.0, 'Batterie' => 200.0, 'SDL' => $sdl,
|
||||
], $this->invoke('leseMessleistungen'));
|
||||
}
|
||||
|
||||
public static function signedPowerProvider(): array
|
||||
{
|
||||
return [
|
||||
'charging' => [1500.0, 1.0, 1500.0, 4300.0],
|
||||
'discharging' => [-1500.0, 1.0, -1500.0, 7300.0],
|
||||
'kilowatts' => [2.5, 1000.0, 2500.0, 3300.0],
|
||||
'inverted source' => [1500.0, -1.0, -1500.0, 7300.0],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider duplicatePowerProvider */
|
||||
public function testRejectsSDLPowerAlsoUsedByAnotherEnergySource(int $duplicateID): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungVariableID' => $duplicateID]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('nicht zugleich');
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
public static function duplicatePowerProvider(): array
|
||||
{
|
||||
return ['grid' => [101], 'PV' => [102], 'battery' => [103]];
|
||||
}
|
||||
|
||||
public function testRejectsSOCUsingThePowerVariable(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 104]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('separate numerische Variable');
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
/** @dataProvider invalidFactorProvider */
|
||||
public function testRejectsInvalidSDLFactors(float $factor): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => $factor]);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
public static function invalidFactorProvider(): array
|
||||
{
|
||||
return ['zero' => [0.0], 'infinite' => [INF], 'not a number' => [NAN]];
|
||||
}
|
||||
|
||||
public function testMissingSDLPowerFailsWithoutReturningHouseConsumption(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
unset(ManagerSDLRuntime::$variables[104]);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('nicht verfuegbar');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
/** @dataProvider invalidPowerProvider */
|
||||
public function testNonfiniteOrNonnumericPowerDoesNotBecomeZero($value): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(104, $value);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('keine endliche Zahl');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
public static function invalidPowerProvider(): array
|
||||
{
|
||||
return ['infinite' => [INF], 'negative infinite' => [-INF], 'not a number' => [NAN], 'string' => ['1500']];
|
||||
}
|
||||
|
||||
public function testScaledSDLPowerOverflowIsRejected(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLLeistungsfaktor' => 2.0]);
|
||||
ManagerSDLRuntime::variable(104, PHP_FLOAT_MAX);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('skalierte SDL-Istleistung');
|
||||
$this->invoke('leseMessleistungen');
|
||||
}
|
||||
|
||||
public function testSDLPowerMustBeANumericVariable(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(104, '1500', 3);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
}
|
||||
|
||||
/** @dataProvider socProvider */
|
||||
public function testSDLSOCBoundaries(float $value, ?float $expected): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(105, $value);
|
||||
self::assertSame($expected, $this->invoke('aktuelleSDLSOC'));
|
||||
if ($expected === null) {
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function socProvider(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [0.0, 0.0], 'full' => [100.0, 100.0], 'normal' => [37.5, 37.5],
|
||||
'negative' => [-0.1, null], 'overfull' => [100.1, null], 'infinite' => [INF, null], 'NaN' => [NAN, null],
|
||||
];
|
||||
}
|
||||
|
||||
public function testSDLSOCMayBeOmittedAndDisappearingSourceIsIgnored(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLSOCVariableID' => 0]);
|
||||
$this->invoke('pruefeSDLKonfiguration');
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
|
||||
$this->properties(['SDLSOCVariableID' => 105]);
|
||||
unset(ManagerSDLRuntime::$variables[105]);
|
||||
self::assertNull($this->invoke('aktuelleSDLSOC'));
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
|
||||
public function testPowerReadingPreservesSOCErrorUntilRecovery(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
ManagerSDLRuntime::variable(105, 101.0);
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertStringContainsString('SDL-SOC', $this->manager->GetValue('SDLStatus'));
|
||||
ManagerSDLRuntime::variable(105, 47.5);
|
||||
$this->invoke('aktuelleSDLLeistung');
|
||||
self::assertSame('Aktiv', $this->manager->GetValue('SDLStatus'));
|
||||
}
|
||||
|
||||
public function testSDLSOCDoesNotChangeWeightedNormalBatterySOC(): void
|
||||
{
|
||||
ManagerSDLRuntime::variable(106, 20.0);
|
||||
ManagerSDLRuntime::variable(107, 80.0);
|
||||
$this->properties([
|
||||
'SDLAktiv' => true,
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'small', 'SOCVariableID' => 106, 'Nutzkapazitaet_kWh' => 10.0],
|
||||
['ID' => 'large', 'SOCVariableID' => 107, 'Nutzkapazitaet_kWh' => 30.0],
|
||||
]),
|
||||
]);
|
||||
|
||||
self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc'));
|
||||
self::assertSame(70.0, $this->invoke('aktuelleSDLSOC'));
|
||||
ManagerSDLRuntime::variable(105, 0.0);
|
||||
self::assertSame(65.0, $this->invoke('aktuelleBatterieSoc'));
|
||||
}
|
||||
|
||||
public function testCounterDeltasExcludeSDLChargingAndIncludeDischarging(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$baseline = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
self::assertCount(8, $baseline);
|
||||
self::assertSame(array_fill_keys(array_keys($baseline), 0.0), $baseline);
|
||||
$this->advanceCounters();
|
||||
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
|
||||
self::assertEqualsWithDelta(2.1, $deltas['Hausenergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001);
|
||||
self::assertSame(0.6, $deltas['SDLLadenEnergie']);
|
||||
self::assertSame(0.2, $deltas['SDLEntladenEnergie']);
|
||||
self::assertEqualsWithDelta(0.4, $deltas['BatterieLadenEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(0.1, $deltas['BatterieEntladenEnergie'], 0.000001);
|
||||
}
|
||||
|
||||
public function testMissingSDLIntervalDoesNotPolluteHouseEnergyOrTheNextInterval(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$this->invoke('leseEnergieZaehlerDeltas');
|
||||
$this->advanceCounters();
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.0, 0.0, false]);
|
||||
|
||||
self::assertSame(0.0, $deltas['Hausenergie']);
|
||||
self::assertEqualsWithDelta(2.0, $deltas['PVEnergie'], 0.000001);
|
||||
self::assertEqualsWithDelta(1.0, $deltas['NetzbezugEnergie'], 0.000001);
|
||||
ManagerSDLRuntime::variable(201, 102.5);
|
||||
$next = $this->invoke('leseEnergieZaehlerDeltas', [0.1, 0.0, true]);
|
||||
self::assertEqualsWithDelta(0.4, $next['Hausenergie'], 0.000001);
|
||||
}
|
||||
|
||||
public function testCounterResetSuppressesHouseEnergyForActiveSDL(): void
|
||||
{
|
||||
$this->configureCounters();
|
||||
$this->invoke('leseEnergieZaehlerDeltas');
|
||||
$this->advanceCounters();
|
||||
ManagerSDLRuntime::variable(202, 0.0);
|
||||
$deltas = $this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]);
|
||||
self::assertSame(0.0, $deltas['Hausenergie']);
|
||||
self::assertSame(0.0, $deltas['NetzbezugEnergie']);
|
||||
self::assertSame(0.6, $deltas['SDLLadenEnergie']);
|
||||
}
|
||||
|
||||
public function testWithoutConfiguredCountersReturnsNull(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
self::assertNull($this->invoke('leseEnergieZaehlerDeltas', [0.6, 0.2]));
|
||||
}
|
||||
|
||||
public function testChangingSDLSourceResetsBaselinesButKeepsCollectedEnergy(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true]);
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
$this->manager->testAttributes['MessungLetzterZeitstempel'] = 123;
|
||||
$this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung'] = 100;
|
||||
$this->manager->testAttributes['MessungZaehlerstaende'] = '{"pv:pv":5.0}';
|
||||
$this->manager->testAttributes['MessungEnergiePuffer'] = '{"Hausenergie":1.5}';
|
||||
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
self::assertSame(123, $this->manager->testAttributes['MessungLetzterZeitstempel']);
|
||||
$this->properties(['SDLLeistungsfaktor' => -1.0]);
|
||||
$this->invoke('synchronisiereSDLMesskonfiguration');
|
||||
|
||||
self::assertSame(0, $this->manager->testAttributes['MessungLetzterZeitstempel']);
|
||||
self::assertSame(0, $this->manager->testAttributes['MessungLetzteLeistungsaufzeichnung']);
|
||||
self::assertSame('{}', $this->manager->testAttributes['MessungZaehlerstaende']);
|
||||
self::assertSame('{"Hausenergie":1.5}', $this->manager->testAttributes['MessungEnergiePuffer']);
|
||||
}
|
||||
|
||||
/** @dataProvider chartProvider */
|
||||
public function testChartMergePreservesCustomizationsAndOnlyRemovesSDL(bool $energy): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLDiagrammeAnzeigen' => true]);
|
||||
$powerIdent = $energy ? 'SDLLadenEnergie' : 'SDLLeistungArchiv';
|
||||
$custom = ['variableID' => 999, 'title' => 'Custom series', 'strokeColor' => '#ABCDEF'];
|
||||
$sdl = ['variableID' => $this->manager->GetIDForIdent($powerIdent), 'title' => 'Own SDL name', 'visible' => false];
|
||||
ManagerSDLRuntime::$media[301] = base64_encode($this->json([
|
||||
'datasets' => [$custom, $sdl], 'customOption' => ['keep' => true],
|
||||
]));
|
||||
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
$chart = $this->chart();
|
||||
self::assertSame($custom, $chart['datasets'][0]);
|
||||
self::assertSame($sdl, $chart['datasets'][1]);
|
||||
self::assertSame(['keep' => true], $chart['customOption']);
|
||||
self::assertCount($energy ? 4 : 3, $chart['datasets']);
|
||||
$soc = $chart['datasets'][count($chart['datasets']) - 1];
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $soc['variableID']);
|
||||
self::assertSame('right', $soc['side']);
|
||||
if ($energy) {
|
||||
self::assertSame('bar', $chart['datasets'][2]['type']);
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLEntladenEnergie'), $chart['datasets'][2]['variableID']);
|
||||
}
|
||||
|
||||
$writes = ManagerSDLRuntime::$writes;
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
self::assertSame($writes, ManagerSDLRuntime::$writes);
|
||||
|
||||
$this->properties(['SDLDiagrammeAnzeigen' => false]);
|
||||
$this->invoke('synchronisiereSDLDiagramm', [301, $energy]);
|
||||
self::assertSame([$custom], $this->chart()['datasets']);
|
||||
self::assertSame(['keep' => true], $this->chart()['customOption']);
|
||||
}
|
||||
|
||||
public static function chartProvider(): array
|
||||
{
|
||||
return ['power chart' => [false], 'energy chart' => [true]];
|
||||
}
|
||||
|
||||
public function testEnergyFlowMergePreservesCustomNodesAndIsIdempotent(): void
|
||||
{
|
||||
$this->properties(['SDLAktiv' => true, 'SDLEnergieflussAnzeigen' => true]);
|
||||
$custom = ['VariableID' => 999, 'Name' => 'Custom consumer', 'Type' => 'consumer', 'Invert' => true];
|
||||
ManagerSDLRuntime::$properties[302]['Config'] = $this->json([$custom]);
|
||||
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
$nodes = $this->flow();
|
||||
self::assertSame($custom, $nodes[0]);
|
||||
self::assertCount(2, $nodes);
|
||||
self::assertSame('storage', $nodes[1]['Type']);
|
||||
self::assertSame($this->manager->GetIDForIdent('EnergieflussSDL'), $nodes[1]['VariableID']);
|
||||
self::assertSame($this->manager->GetIDForIdent('SDLSOCArchiv'), $nodes[1]['InfoVariableID']);
|
||||
self::assertTrue(ManagerSDLRuntime::$hidden[$nodes[1]['VariableID']]);
|
||||
|
||||
$nodes[1]['Name'] = 'My SDL';
|
||||
$nodes[1]['Invert'] = true;
|
||||
ManagerSDLRuntime::$properties[302]['Config'] = $this->json($nodes);
|
||||
$writes = ManagerSDLRuntime::$writes;
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame($nodes, $this->flow());
|
||||
self::assertSame($writes, ManagerSDLRuntime::$writes);
|
||||
|
||||
$this->properties(['SDLSOCVariableID' => 0]);
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame(1, $this->flow()[1]['InfoVariableID']);
|
||||
self::assertSame('My SDL', $this->flow()[1]['Name']);
|
||||
|
||||
$this->properties(['SDLAktiv' => false]);
|
||||
$this->invoke('synchronisiereSDLEnergiefluss', [302]);
|
||||
self::assertSame([$custom], $this->flow());
|
||||
}
|
||||
|
||||
private function configureCounters(): void
|
||||
{
|
||||
$this->properties([
|
||||
'SDLAktiv' => true,
|
||||
'NetzbezugEnergieVariableID' => 202,
|
||||
'NetzeinspeisungEnergieVariableID' => 203,
|
||||
'AnlagenWechselrichter' => $this->json([
|
||||
['ID' => 'pv', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'ErzeugungsenergieVariableID' => 201],
|
||||
]),
|
||||
'AnlagenBatterien' => $this->json([
|
||||
['ID' => 'battery', 'LeistungVariableID' => 103, 'LadeenergieVariableID' => 204, 'EntladeenergieVariableID' => 205],
|
||||
]),
|
||||
]);
|
||||
foreach ([201 => 100.0, 202 => 10.0, 203 => 5.0, 204 => 10.0, 205 => 8.0] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private function advanceCounters(): void
|
||||
{
|
||||
foreach ([201 => 102.0, 202 => 11.0, 203 => 5.2, 204 => 10.4, 205 => 8.1] as $id => $value) {
|
||||
ManagerSDLRuntime::variable($id, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private function properties(array $properties): void
|
||||
{
|
||||
$this->manager->testProperties = array_replace($this->manager->testProperties, $properties);
|
||||
}
|
||||
|
||||
private function invoke(string $name, array $arguments = [])
|
||||
{
|
||||
$method = new ReflectionMethod(Manager::class, $name);
|
||||
if (PHP_VERSION_ID < 80100) {
|
||||
$method->setAccessible(true);
|
||||
}
|
||||
|
||||
return $method->invokeArgs($this->manager, $arguments);
|
||||
}
|
||||
|
||||
private function json(array $value): string
|
||||
{
|
||||
return json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
|
||||
}
|
||||
|
||||
private function chart(): array
|
||||
{
|
||||
return json_decode(base64_decode(ManagerSDLRuntime::$media[301]), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function flow(): array
|
||||
{
|
||||
return json_decode(ManagerSDLRuntime::$properties[302]['Config'], true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user