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); } }