From db704dd011d9cf65f08b105f7a9f8ca1de6fd3be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fliger?= Date: Sun, 27 Sep 2026 08:48:59 +0000 Subject: [PATCH] feat: Energiediagramm adaptieren --- Energiediagramm/README.md | 56 ++ Energiediagramm/form.json | 97 ++++ .../libs/EnergyChartCalculator.php | 134 +++++ Energiediagramm/module.html | 477 +++++++++++++++++ Energiediagramm/module.json | 14 + Energiediagramm/module.php | 482 ++++++++++++++++++ README.md | 11 +- docs/module/Energiediagramm/README.md | 68 ++- docs/module/README.md | 6 +- docs/testing/README.md | 3 +- tests/DokumentationsstrukturTest.php | 2 +- tests/EnergiediagrammModulstrukturTest.php | 69 +++ tests/EnergiediagrammTest.php | 118 +++++ tests/Symcon/manifest.php | 6 + tests/Symcon/modules/Energiediagramm.php | 61 +++ tests/SymconTestContractTest.php | 2 +- 16 files changed, 1576 insertions(+), 30 deletions(-) create mode 100644 Energiediagramm/README.md create mode 100644 Energiediagramm/form.json create mode 100644 Energiediagramm/libs/EnergyChartCalculator.php create mode 100644 Energiediagramm/module.html create mode 100644 Energiediagramm/module.json create mode 100644 Energiediagramm/module.php create mode 100644 tests/EnergiediagrammModulstrukturTest.php create mode 100644 tests/EnergiediagrammTest.php create mode 100644 tests/Symcon/modules/Energiediagramm.php diff --git a/Energiediagramm/README.md b/Energiediagramm/README.md new file mode 100644 index 0000000..aced640 --- /dev/null +++ b/Energiediagramm/README.md @@ -0,0 +1,56 @@ +# Energiediagramm + +Das Energiediagramm stellt Produktion, Hausverbrauch, Netzbezug und +Einspeisung aus archivierten Energiezaehlern dar. Zwei Kreisdiagramme zeigen +Eigenverbrauchsquote und Autarkiegrad. + +## Voraussetzungen + +- IP-Symcon ab Version 8.0 +- aktiviertes Archiv +- fortlaufende Integer- oder Floatzaehler +- Zaehlerwerte in kWh oder ein passender gemeinsamer Zaehlerfaktor + +## Konfiguration + +Produktion, Einspeisung und Netzbezug sind Pflichtquellen. Ein vorhandener +Hausverbrauchszaehler kann optional direkt verwendet werden. Ohne diese Quelle +wird der Hausverbrauch als Produktion minus Einspeisung plus Netzbezug +berechnet. + +Der Standardzeitraum kann auf aktuellen Tag, aktuelle Woche, aktuellen Monat, +aktuelles Jahr oder Gesamt gesetzt werden. In der Visualisierung stehen +zusaetzlich ein frei waehlbarer inklusiver Datumsbereich und die Navigation +durch abgeschlossene Zeitraeume zur Verfuegung. + +Die Farben fuer Eigenverbrauchsquote und Autarkiegrad werden mit den beiden +Farbauswahlen in der Instanzkonfiguration festgelegt. + +## Darstellung + +Die Kachel und ihre vergroesserte Ansicht erhalten beim Aufbau sofort einen +vollstaendigen Datenstand. Beim Oeffnen und beim erneuten Sichtbarwerden wird +die Darstellung aktualisiert. Das Diagrammraster wechselt bei geringer Breite +automatisch von zwei Spalten auf eine Spalte. + +## Berechnung + +- Hausverbrauch: direkter Zaehler oder Produktion - Einspeisung + Netzbezug +- Eigenverbrauch: Hausverbrauch - Netzbezug, begrenzt auf die Produktion +- Eigenverbrauchsquote: Eigenverbrauch / Produktion +- Autarkiegrad: Eigenverbrauch / Hausverbrauch + +Negative Zaehlerdifferenzen, etwa nach einem Zaehlerwechsel, werden fuer den +betroffenen Zeitraum auf 0 kWh begrenzt. + +## Migration aus Enelix 1 + +Uebernommen wurden die drei Pflichtzaehler, die Zeitraumsauswahl, die +Datumsnavigation, die Energiebilanz und beide Kreisdiagramme. Neu sind die +optionale direkte Hausverbrauchsquelle, der konfigurierbare Standardzeitraum, +der freie Datumsbereich, waehlbare Farben, eingebettete Initialdaten fuer die +vergroesserte Ansicht und das responsive Ein-/Zweispaltenlayout. + +Das Enelix-1-Modul wird nicht in-place aktualisiert. Es muss eine neue +Energiediagramm-Instanz angelegt und mit den bisherigen Zaehlern konfiguriert +werden. diff --git a/Energiediagramm/form.json b/Energiediagramm/form.json new file mode 100644 index 0000000..80dcaa1 --- /dev/null +++ b/Energiediagramm/form.json @@ -0,0 +1,97 @@ +{ + "elements": [ + { + "type": "Label", + "caption": "Energiezaehler muessen als fortlaufende kWh-Zaehler im Archiv protokolliert werden." + }, + { + "type": "SelectVariable", + "name": "ProduktionZaehlerID", + "caption": "Produktion" + }, + { + "type": "SelectVariable", + "name": "EinspeisungZaehlerID", + "caption": "Einspeisung" + }, + { + "type": "SelectVariable", + "name": "NetzbezugZaehlerID", + "caption": "Netzbezug" + }, + { + "type": "SelectVariable", + "name": "VerbrauchZaehlerID", + "caption": "Hausverbrauch (optional)" + }, + { + "type": "NumberSpinner", + "name": "Zaehlerfaktor", + "caption": "Zaehlerfaktor auf kWh", + "minimum": 0.000001, + "maximum": 1000000, + "digits": 6 + }, + { + "type": "Select", + "name": "Standardzeitraum", + "caption": "Standardzeitraum", + "options": [ + { "caption": "Aktueller Tag", "value": "day" }, + { "caption": "Aktuelle Woche", "value": "week" }, + { "caption": "Aktueller Monat", "value": "month" }, + { "caption": "Aktuelles Jahr", "value": "year" }, + { "caption": "Gesamt", "value": "total" } + ] + }, + { + "type": "NumberSpinner", + "name": "Aktualisierungsintervall", + "caption": "Aktualisierungsintervall", + "minimum": 10, + "maximum": 3600, + "suffix": " Sekunden" + }, + { + "type": "RowLayout", + "items": [ + { + "type": "SelectColor", + "name": "FarbeEigenverbrauch", + "caption": "Eigenverbrauchsquote", + "allowTransparent": false + }, + { + "type": "SelectColor", + "name": "FarbeAutarkie", + "caption": "Autarkiegrad", + "allowTransparent": false + } + ] + }, + { + "type": "CheckBox", + "name": "Debug", + "caption": "Debug-Logging aktivieren" + } + ], + "actions": [ + { + "type": "Button", + "caption": "Darstellung aktualisieren", + "onClick": "IPS_RequestAction($id, 'Refresh', true);" + } + ], + "status": [ + { + "code": 201, + "icon": "error", + "caption": "Konfiguration ungueltig" + }, + { + "code": 202, + "icon": "error", + "caption": "Archiv nicht verfuegbar" + } + ] +} diff --git a/Energiediagramm/libs/EnergyChartCalculator.php b/Energiediagramm/libs/EnergyChartCalculator.php new file mode 100644 index 0000000..fb6c0af --- /dev/null +++ b/Energiediagramm/libs/EnergyChartCalculator.php @@ -0,0 +1,134 @@ +format('Y-m-d') === $date; + } + + /** + * @return array{0: int, 1: int} + */ + public static function rangeBounds( + string $range, + string $date, + string $customStart, + string $customEnd, + ?int $now = null + ): array { + if (!in_array($range, self::RANGES, true)) { + throw new InvalidArgumentException('Unbekannter Zeitraum.'); + } + + $now ??= time(); + if ($range === 'total') { + return [0, $now]; + } + + if ($range === 'custom') { + $start = self::date($customStart); + $end = self::date($customEnd); + if ($start > $end) { + throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.'); + } + + return [$start->getTimestamp(), $end->modify('+1 day')->getTimestamp()]; + } + + $base = self::date($date); + switch ($range) { + case 'day': + $start = $base; + $end = $start->modify('+1 day'); + break; + case 'week': + $start = $base->modify('monday this week'); + $end = $start->modify('+1 week'); + break; + case 'month': + $start = $base->modify('first day of this month'); + $end = $start->modify('first day of next month'); + break; + case 'year': + $start = $base->setDate((int) $base->format('Y'), 1, 1); + $end = $start->modify('+1 year'); + break; + default: + throw new InvalidArgumentException('Unbekannter Zeitraum.'); + } + + return [$start->getTimestamp(), $end->getTimestamp()]; + } + + /** + * @return array{ + * Produktion: float, + * Einspeisung: float, + * Netzbezug: float, + * Hausverbrauch: float, + * Eigenverbrauch: float, + * Eigenverbrauchsquote: float, + * Autarkiegrad: float + * } + */ + public static function metrics( + float $production, + float $feedIn, + float $grid, + ?float $consumption = null + ): array { + $production = max(0.0, $production); + $feedIn = max(0.0, $feedIn); + $grid = max(0.0, $grid); + $house = $consumption === null + ? max(0.0, $production - $feedIn + $grid) + : max(0.0, $consumption); + $selfConsumption = min($production, max(0.0, $house - $grid)); + + return [ + 'Produktion' => $production, + 'Einspeisung' => $feedIn, + 'Netzbezug' => $grid, + 'Hausverbrauch' => $house, + 'Eigenverbrauch' => $selfConsumption, + 'Eigenverbrauchsquote' => $production > 0.0 + ? min(100.0, ($selfConsumption / $production) * 100.0) + : 0.0, + 'Autarkiegrad' => $house > 0.0 + ? min(100.0, ($selfConsumption / $house) * 100.0) + : 0.0, + ]; + } + + public static function normalizeColor(string $color, string $fallback): string + { + return preg_match('/^#[0-9A-Fa-f]{6}$/', $color) === 1 + ? strtoupper($color) + : strtoupper($fallback); + } + + private static function date(string $date): DateTimeImmutable + { + if (!self::isValidDate($date)) { + throw new InvalidArgumentException('Ungültiges Datum.'); + } + + return new DateTimeImmutable($date . ' 00:00:00', new DateTimeZone(date_default_timezone_get())); + } +} diff --git a/Energiediagramm/module.html b/Energiediagramm/module.html new file mode 100644 index 0000000..f6bb330 --- /dev/null +++ b/Energiediagramm/module.html @@ -0,0 +1,477 @@ +
+
+ + + + + + + +
+ +
+ + +
+ +
+
+

Eigenverbrauchsquote

+
+
+
+

Autarkiegrad

+
+
+
+ + +
+ + + + diff --git a/Energiediagramm/module.json b/Energiediagramm/module.json new file mode 100644 index 0000000..76a2305 --- /dev/null +++ b/Energiediagramm/module.json @@ -0,0 +1,14 @@ +{ + "id": "{F73B8F8D-5E36-4B23-B379-4632A517A1D6}", + "name": "Energiediagramm", + "type": 3, + "vendor": "Enelix", + "aliases": [ + "Energy Pie" + ], + "parentRequirements": [], + "childRequirements": [], + "implemented": [], + "prefix": "ENELIXENERGY", + "url": "" +} diff --git a/Energiediagramm/module.php b/Energiediagramm/module.php new file mode 100644 index 0000000..d17ff27 --- /dev/null +++ b/Energiediagramm/module.php @@ -0,0 +1,482 @@ +RegisterPropertyInteger('ProduktionZaehlerID', 0); + $this->RegisterPropertyInteger('EinspeisungZaehlerID', 0); + $this->RegisterPropertyInteger('NetzbezugZaehlerID', 0); + $this->RegisterPropertyInteger('VerbrauchZaehlerID', 0); + $this->RegisterPropertyFloat('Zaehlerfaktor', 1.0); + $this->RegisterPropertyString('Standardzeitraum', 'day'); + $this->RegisterPropertyInteger('Aktualisierungsintervall', 60); + $this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION); + $this->RegisterPropertyString('FarbeAutarkie', self::COLOR_AUTARKY); + $this->RegisterPropertyBoolean('Debug', false); + + $today = date('Y-m-d'); + $this->RegisterAttributeString(self::ATTR_RANGE, 'day'); + $this->RegisterAttributeString(self::ATTR_DATE, $today); + $this->RegisterAttributeString(self::ATTR_CUSTOM_START, $today); + $this->RegisterAttributeString(self::ATTR_CUSTOM_END, $today); + + $this->SetVisualizationType(1); + $this->RegisterTimer( + 'Refresh', + 0, + 'IPS_RequestAction($_IPS["TARGET"], "Refresh", true);' + ); + } + + public function ApplyChanges(): void + { + parent::ApplyChanges(); + + $today = date('Y-m-d'); + $defaultRange = $this->ReadPropertyString('Standardzeitraum'); + if (!in_array($defaultRange, ['day', 'week', 'month', 'year', 'total'], true)) { + $defaultRange = 'day'; + } + + $this->WriteAttributeString(self::ATTR_RANGE, $defaultRange); + $this->WriteAttributeString(self::ATTR_DATE, $today); + if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_START))) { + $this->WriteAttributeString(self::ATTR_CUSTOM_START, $today); + } + if (!$this->validStoredDate($this->ReadAttributeString(self::ATTR_CUSTOM_END))) { + $this->WriteAttributeString(self::ATTR_CUSTOM_END, $today); + } + + $interval = $this->ReadPropertyInteger('Aktualisierungsintervall'); + $this->SetTimerInterval('Refresh', max(10, min(3600, $interval)) * 1000); + + if ($this->requiredSourceIdsAreEmpty()) { + $this->SetStatus(104); + } elseif (!$this->configurationIsValid()) { + $this->SetStatus(201); + } elseif ($this->archiveId() === 0) { + $this->SetStatus(202); + } else { + $this->SetStatus(102); + } + + $this->recalculateAndPush(); + } + + public function GetVisualizationTile(): string + { + $path = __DIR__ . '/module.html'; + $html = @file_get_contents($path); + if ($html === false) { + return '
Darstellung nicht verfügbar.
'; + } + + try { + $initial = json_encode( + $this->buildPayload(), + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR + ); + } catch (Throwable $error) { + $initial = '{"hasData":false,"noDataHint":"Darstellung konnte nicht geladen werden."}'; + } + + return str_replace('__ENELIX_INITIAL_STATE__', $initial, $html); + } + + public function GetVisualizationPopup(): string + { + return $this->GetVisualizationTile(); + } + + public function RequestAction($Ident, $Value): void + { + try { + switch ((string) $Ident) { + case 'SetRange': + $this->setRange((string) $Value); + break; + case 'SetDate': + $this->setDate((string) $Value); + break; + case 'SetCustomRange': + $this->setCustomRange((string) $Value); + break; + case 'Prev': + case 'Next': + case 'Today': + $this->shiftDate((string) $Ident); + break; + case 'Refresh': + break; + default: + throw new InvalidArgumentException('Unbekannte Aktion.'); + } + + $this->recalculateAndPush(); + } catch (Throwable $error) { + $this->debug('Aktion abgewiesen', $error->getMessage()); + } + } + + private function setRange(string $range): void + { + if (!in_array($range, EnergyChartCalculator::RANGES, true)) { + throw new InvalidArgumentException('Unbekannter Zeitraum.'); + } + + $this->WriteAttributeString(self::ATTR_RANGE, $range); + } + + private function setDate(string $date): void + { + if (!$this->validStoredDate($date)) { + throw new InvalidArgumentException('Ungültiges oder zukünftiges Datum.'); + } + + $this->WriteAttributeString(self::ATTR_DATE, $date); + } + + private function setCustomRange(string $json): void + { + try { + $value = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new InvalidArgumentException('Ungültiger Datumsbereich.', 0, $error); + } + + $start = is_array($value) ? (string) ($value['start'] ?? '') : ''; + $end = is_array($value) ? (string) ($value['end'] ?? '') : ''; + if (!$this->validStoredDate($start) || !$this->validStoredDate($end)) { + throw new InvalidArgumentException('Ungültiger oder zukünftiger Datumsbereich.'); + } + if ($start > $end) { + throw new InvalidArgumentException('Das Startdatum liegt nach dem Enddatum.'); + } + + $this->WriteAttributeString(self::ATTR_CUSTOM_START, $start); + $this->WriteAttributeString(self::ATTR_CUSTOM_END, $end); + $this->WriteAttributeString(self::ATTR_RANGE, 'custom'); + } + + private function shiftDate(string $action): void + { + $range = $this->ReadAttributeString(self::ATTR_RANGE); + if ($range === 'total' || $range === 'custom') { + return; + } + + if ($action === 'Today') { + $this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d')); + return; + } + + $date = $this->ReadAttributeString(self::ATTR_DATE); + $base = strtotime($date . ' 00:00:00'); + if ($base === false) { + $base = strtotime(date('Y-m-d') . ' 00:00:00'); + } + + $previous = $action === 'Prev'; + $modifier = [ + 'day' => $previous ? '-1 day' : '+1 day', + 'week' => $previous ? '-1 week' : '+1 week', + 'month' => $previous ? 'first day of previous month' : 'first day of next month', + 'year' => $previous ? 'first day of january previous year' : 'first day of january next year', + ][$range] ?? 'today'; + + $shifted = strtotime($modifier, $base); + if ($shifted === false || $shifted > time()) { + $shifted = time(); + } + $this->WriteAttributeString(self::ATTR_DATE, date('Y-m-d', $shifted)); + } + + private function recalculateAndPush(): void + { + try { + $payload = $this->buildPayload(); + $this->UpdateVisualizationValue(json_encode($payload, JSON_THROW_ON_ERROR)); + } catch (Throwable $error) { + $this->debug('Aktualisierung fehlgeschlagen', $error->getMessage()); + } + } + + /** + * @return array + */ + private function buildPayload(): array + { + $range = $this->ReadAttributeString(self::ATTR_RANGE); + if (!in_array($range, EnergyChartCalculator::RANGES, true)) { + $range = $this->ReadPropertyString('Standardzeitraum'); + } + if (!in_array($range, EnergyChartCalculator::RANGES, true)) { + $range = 'day'; + } + + $date = $this->ReadAttributeString(self::ATTR_DATE); + if (!$this->validStoredDate($date)) { + $date = date('Y-m-d'); + } + $customStart = $this->ReadAttributeString(self::ATTR_CUSTOM_START); + $customEnd = $this->ReadAttributeString(self::ATTR_CUSTOM_END); + if (!$this->validStoredDate($customStart)) { + $customStart = date('Y-m-d'); + } + if (!$this->validStoredDate($customEnd)) { + $customEnd = date('Y-m-d'); + } + + [$start, $end] = EnergyChartCalculator::rangeBounds( + $range, + $date, + $customStart, + $customEnd + ); + + $payload = [ + 'range' => $range, + 'date' => $date, + 'customStart' => $customStart, + 'customEnd' => $customEnd, + 'tStart' => $start, + 'tEnd' => $end, + 'hasData' => false, + 'noDataHint' => 'Energiezähler konfigurieren.', + 'colors' => [ + 'selfConsumption' => EnergyChartCalculator::normalizeColor( + $this->ReadPropertyString('FarbeEigenverbrauch'), + self::COLOR_SELF_CONSUMPTION + ), + 'autarky' => EnergyChartCalculator::normalizeColor( + $this->ReadPropertyString('FarbeAutarkie'), + self::COLOR_AUTARKY + ), + ], + 'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0), + ]; + + if (!$this->configurationIsValid()) { + return $payload; + } + + $archiveId = $this->archiveId(); + if ($archiveId === 0) { + $payload['noDataHint'] = 'Das IP-Symcon-Archiv ist nicht verfügbar.'; + return $payload; + } + + $factor = $this->ReadPropertyFloat('Zaehlerfaktor'); + $total = $range === 'total'; + $production = $this->readEnergy( + $archiveId, + $this->ReadPropertyInteger('ProduktionZaehlerID'), + $start, + $end, + $total + ); + $feedIn = $this->readEnergy( + $archiveId, + $this->ReadPropertyInteger('EinspeisungZaehlerID'), + $start, + $end, + $total + ); + $grid = $this->readEnergy( + $archiveId, + $this->ReadPropertyInteger('NetzbezugZaehlerID'), + $start, + $end, + $total + ); + + $consumption = null; + $consumptionAvailable = true; + $consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID'); + if ($consumptionId > 0) { + $consumptionResult = $this->readEnergy($archiveId, $consumptionId, $start, $end, $total); + $consumptionAvailable = $consumptionResult['available']; + if ($consumptionAvailable) { + $consumption = $consumptionResult['value'] * $factor; + } + } + + $payload['hasData'] = $production['available'] + && $feedIn['available'] + && $grid['available'] + && $consumptionAvailable; + $payload['values'] = EnergyChartCalculator::metrics( + $production['value'] * $factor, + $feedIn['value'] * $factor, + $grid['value'] * $factor, + $consumption + ); + + if (!$payload['hasData']) { + $lastLog = max( + $this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('ProduktionZaehlerID')), + $this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('EinspeisungZaehlerID')), + $this->lastLogTimestamp($archiveId, $this->ReadPropertyInteger('NetzbezugZaehlerID')), + $this->lastLogTimestamp($archiveId, $consumptionId) + ); + $payload['noDataHint'] = $lastLog > 0 + ? 'Letzter Archivwert: ' . date('d.m.Y H:i', $lastLog) + : 'Für diesen Zeitraum sind keine Archivwerte vorhanden.'; + } else { + $payload['noDataHint'] = ''; + } + + return $payload; + } + + /** + * @return array{value: float, available: bool} + */ + private function readEnergy( + int $archiveId, + int $variableId, + int $start, + int $end, + bool $total + ): array { + if (!$this->isNumericVariable($variableId)) { + return ['value' => 0.0, 'available' => false]; + } + + if ($total) { + $value = GetValue($variableId); + return [ + 'value' => is_numeric($value) ? max(0.0, (float) $value) : 0.0, + 'available' => is_numeric($value), + ]; + } + + $before = @AC_GetLoggedValues($archiveId, $variableId, 0, $start, 1); + $within = @AC_GetLoggedValues($archiveId, $variableId, $start, min($end, time()), 0); + $before = is_array($before) ? $before : []; + $within = is_array($within) ? $within : []; + usort($before, static fn (array $left, array $right): int => + ((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp']) + ); + usort($within, static fn (array $left, array $right): int => + ((int) $left['TimeStamp']) <=> ((int) $right['TimeStamp']) + ); + + if ($before === [] && $within === []) { + return ['value' => 0.0, 'available' => false]; + } + + $startValue = $before !== [] + ? (float) $before[count($before) - 1]['Value'] + : (float) $within[0]['Value']; + $endValue = $within !== [] + ? (float) $within[count($within) - 1]['Value'] + : $startValue; + + return [ + 'value' => max(0.0, $endValue - $startValue), + 'available' => true, + ]; + } + + private function lastLogTimestamp(int $archiveId, int $variableId): int + { + if (!$this->isNumericVariable($variableId)) { + return 0; + } + + $values = @AC_GetLoggedValues($archiveId, $variableId, 0, time(), 1); + return is_array($values) && isset($values[0]['TimeStamp']) + ? (int) $values[0]['TimeStamp'] + : 0; + } + + private function configurationIsValid(): bool + { + $requiredIds = [ + $this->ReadPropertyInteger('ProduktionZaehlerID'), + $this->ReadPropertyInteger('EinspeisungZaehlerID'), + $this->ReadPropertyInteger('NetzbezugZaehlerID'), + ]; + foreach ($requiredIds as $variableId) { + if (!$this->isNumericVariable($variableId)) { + return false; + } + } + + $consumptionId = $this->ReadPropertyInteger('VerbrauchZaehlerID'); + if ($consumptionId > 0 && !$this->isNumericVariable($consumptionId)) { + return false; + } + + return $this->ReadPropertyFloat('Zaehlerfaktor') > 0.0 + && $this->ReadPropertyInteger('Aktualisierungsintervall') >= 10 + && $this->ReadPropertyInteger('Aktualisierungsintervall') <= 3600 + && in_array( + $this->ReadPropertyString('Standardzeitraum'), + ['day', 'week', 'month', 'year', 'total'], + true + ) + && EnergyChartCalculator::normalizeColor( + $this->ReadPropertyString('FarbeEigenverbrauch'), + '' + ) !== '' + && EnergyChartCalculator::normalizeColor( + $this->ReadPropertyString('FarbeAutarkie'), + '' + ) !== ''; + } + + private function requiredSourceIdsAreEmpty(): bool + { + return $this->ReadPropertyInteger('ProduktionZaehlerID') === 0 + && $this->ReadPropertyInteger('EinspeisungZaehlerID') === 0 + && $this->ReadPropertyInteger('NetzbezugZaehlerID') === 0; + } + + private function archiveId(): int + { + $archives = IPS_GetInstanceListByModuleID(self::ARCHIVE_MODULE_ID); + return isset($archives[0]) ? (int) $archives[0] : 0; + } + + private function isNumericVariable(int $variableId): bool + { + if ($variableId <= 0 || !IPS_VariableExists($variableId)) { + return false; + } + + $variable = IPS_GetVariable($variableId); + return in_array((int) ($variable['VariableType'] ?? -1), [1, 2], true); + } + + private function validStoredDate(string $date): bool + { + return EnergyChartCalculator::isValidDate($date) + && $date <= date('Y-m-d'); + } + + private function debug(string $title, string $message): void + { + if ($this->ReadPropertyBoolean('Debug')) { + $this->SendDebug($title, $message, 0); + } + } +} diff --git a/README.md b/README.md index 02acbef..fcab138 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix ## Status -Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly -Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module -sind derzeit als Diskussionsentwürfe dokumentiert. +Das Repository befindet sich im Aufbau. Verbrauchskostenreport, +Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module +enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert. ## Module - Verbrauchskostenreport (implementiert) - Virtuelle Batterie - CC100 Hardware -- Energiediagramm +- Energiediagramm (implementiert) - VGT-Schnittstelle - Shelly Modul (implementiert) @@ -56,9 +56,10 @@ da IP-Symcon sonst URL, Branch und Aktualisierungsstatus nicht verwalten kann. ## Aktualisierung und Prüfung Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung -bezogen. Nach einem Branchwechsel oder Update müssen beide implementierten +bezogen. Nach einem Branchwechsel oder Update müssen alle implementierten Module geladen sein: +- `Energiediagramm` - `Shelly Modul` - `Verbrauchskostenreport` diff --git a/docs/module/Energiediagramm/README.md b/docs/module/Energiediagramm/README.md index 5ddafe1..4ac64ff 100644 --- a/docs/module/Energiediagramm/README.md +++ b/docs/module/Energiediagramm/README.md @@ -1,29 +1,59 @@ # Energiediagramm -> Status: Diskussionsentwurf. Übernimmt `Energy_Pie` ohne EMS-Abhängigkeit. +> Status: Implementiert fuer IP-Symcon 8.0+. -Der Zeitraum wird einstellbar. Öffnen beziehungsweise Aufklappen aktualisiert -dieselbe Darstellung wie die bestehende Aktualisieren-Aktion. - -## Variablen - -Keine klassischen Pflichtvariablen. Energiewerte und Auswahl erscheinen in der -individuellen Darstellung; Zeitraum und Datum bleiben intern gespeichert. +Das Modul adaptiert `Energy_Pie` als unabhaengiges Enelix-Utils-Modul. Es +wertet fortlaufende Energiezaehler aus dem IP-Symcon-Archiv aus und zeigt die +Energiebilanz sowie Eigenverbrauchsquote und Autarkiegrad in einer individuellen +HTML-SDK-Darstellung. ## Properties | Ident | Typ | Standard / Beschreibung | | --- | --- | --- | -| `ProduktionZaehlerID` | Integer | `0`; alt `VarProduction`. | -| `VerbrauchZaehlerID` | Integer | `0`; alt `VarConsumption`. | -| `EinspeisungZaehlerID` | Integer | `0`; alt `VarFeedIn`. | -| `NetzbezugZaehlerID` | Integer | `0`; alt `VarGrid`. | -| `Zaehlerfaktor` | Float | `1`; auf kWh normieren. | -| `Standardzeitraum` | String/Auswahl | `Tag`; Tag, Woche, Monat, Jahr oder Gesamt. | -| `Aktualisierungsintervall` | Integer | `60` s. | +| `ProduktionZaehlerID` | Integer | Pflichtquelle fuer produzierte Energie | +| `EinspeisungZaehlerID` | Integer | Pflichtquelle fuer eingespeiste Energie | +| `NetzbezugZaehlerID` | Integer | Pflichtquelle fuer bezogene Energie | +| `VerbrauchZaehlerID` | Integer | Optionale direkte Hausverbrauchsquelle | +| `Zaehlerfaktor` | Float | `1.0`; gemeinsamer Faktor auf kWh | +| `Standardzeitraum` | String | `day`; Tag, Woche, Monat, Jahr oder Gesamt | +| `Aktualisierungsintervall` | Integer | `60 s`; 10 bis 3600 Sekunden | +| `FarbeEigenverbrauch` | String | Farbe der Eigenverbrauchsquote | +| `FarbeAutarkie` | String | Farbe des Autarkiegrads | +| `Debug` | Boolean | Zusaetzliche Diagnoseausgaben | -## Verhalten und offene Punkte +## Zeitraeume und Bedienung -- Nutzt Energiezähler und Symcon-Archivdaten. -- Fehlende Historie darf nicht als echter Nullverbrauch dargestellt werden. -- Aktualisierung beim Öffnen in den verfügbaren Symcon-Visualisierungen testen. +Die Visualisierung bietet Tag, Woche, Monat, Jahr, Gesamt und Datumsbereich. +Der freie Datumsbereich umfasst Start- und Enddatum jeweils vollstaendig. Fuer +die Kalenderzeitraeume kann vor- und zuruecknavigiert oder direkt zum aktuellen +Zeitraum gesprungen werden. Zukuenftige Datumswerte werden nicht akzeptiert. + +## Darstellung + +Kachel und vergroesserte Ansicht enthalten bereits beim ersten HTML-Aufbau +einen vollstaendigen Datenstand. Ein nachgelagertes Refresh aktualisiert die +Werte; beim erneuten Sichtbarwerden wird ebenfalls aktualisiert. Das +Diagrammraster zeigt bei ausreichender Breite zwei Kreisdiagramme nebeneinander +und bei geringer Breite automatisch untereinander. + +## Daten und Fehlerverhalten + +Die drei Pflichtzaehler muessen numerisch und im Archiv vorhanden sein. Der +Hausverbrauch wird aus einer optionalen direkten Quelle gelesen oder aus +Produktion minus Einspeisung plus Netzbezug berechnet. Bei fehlendem Archiv +oder fehlenden Werten zeigt die Darstellung einen Diagnosehinweis statt einer +leeren Flaeche. + +## Adaption aus Enelix 1 + +| Bereich | Behandlung | +| --- | --- | +| Archivierte Produktions-, Einspeise- und Netzbezugszaehler | angepasst uebernommen | +| Tag, Woche, Monat, Jahr und Gesamt | uebernommen | +| Frei waehlbarer Datumsbereich | neu implementiert | +| Standardzeitraum in der Konfiguration | neu implementiert | +| Farben der Kreisdiagramme | neu konfigurierbar | +| Vergroesserte Ansicht | mit eingebetteten Initialdaten stabilisiert | +| Starres Zwei-Spalten-Layout | durch responsives Raster ersetzt | +| Erzwungener Sprung auf Heute beim Laden | verworfen | diff --git a/docs/module/README.md b/docs/module/README.md index dea99f4..fad1ea2 100644 --- a/docs/module/README.md +++ b/docs/module/README.md @@ -1,14 +1,14 @@ # Modulübersicht Enelix Utils -> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die -> übrigen Module sind als Diskussionsentwürfe dokumentiert. +> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind +> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert. | Modul | Herkunft | EMS-Abhängigkeit | | --- | --- | --- | | [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine | | [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit | | [CC100 Hardware](CC100-Hardware/README.md) | CC100_HW | Keine | -| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie | Keine | +| [Energiediagramm](Energiediagramm/README.md) | Energy_Pie (implementiert) | Keine | | [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar | | [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine | diff --git a/docs/testing/README.md b/docs/testing/README.md index 5a61761..827d15e 100644 --- a/docs/testing/README.md +++ b/docs/testing/README.md @@ -42,7 +42,8 @@ werden, wenn sie vor dem Lauf nicht existierten. - `single`: genau die als Auswahl übergebenen Module - `affected`: die durch geänderte Pfade ermittelten Module -Verfügbare Module: `ShellyModul`, `Verbrauchskostenreport`. +Verfügbare Module: `Energiediagramm`, `ShellyModul`, +`Verbrauchskostenreport`. Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet. diff --git a/tests/DokumentationsstrukturTest.php b/tests/DokumentationsstrukturTest.php index d7dde0d..7ab9325 100644 --- a/tests/DokumentationsstrukturTest.php +++ b/tests/DokumentationsstrukturTest.php @@ -15,7 +15,7 @@ final class DokumentationsstrukturTest extends TestCase 'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'], 'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'], 'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'], - 'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'], + 'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'], 'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'], 'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'], ]; diff --git a/tests/EnergiediagrammModulstrukturTest.php b/tests/EnergiediagrammModulstrukturTest.php new file mode 100644 index 0000000..efa28ec --- /dev/null +++ b/tests/EnergiediagrammModulstrukturTest.php @@ -0,0 +1,69 @@ +timezone = date_default_timezone_get(); + date_default_timezone_set('Europe/Zurich'); + } + + protected function tearDown(): void + { + date_default_timezone_set($this->timezone); + } + + public function testCalendarRangesUseLocalBoundaries(): void + { + [$dayStart, $dayEnd] = EnergyChartCalculator::rangeBounds( + 'day', + '2026-09-27', + '2026-09-01', + '2026-09-02' + ); + self::assertSame('2026-09-27 00:00', date('Y-m-d H:i', $dayStart)); + self::assertSame('2026-09-28 00:00', date('Y-m-d H:i', $dayEnd)); + + [$weekStart, $weekEnd] = EnergyChartCalculator::rangeBounds( + 'week', + '2026-09-27', + '2026-09-01', + '2026-09-02' + ); + self::assertSame('2026-09-21', date('Y-m-d', $weekStart)); + self::assertSame('2026-09-28', date('Y-m-d', $weekEnd)); + + [$monthStart, $monthEnd] = EnergyChartCalculator::rangeBounds( + 'month', + '2026-09-27', + '2026-09-01', + '2026-09-02' + ); + self::assertSame('2026-09-01', date('Y-m-d', $monthStart)); + self::assertSame('2026-10-01', date('Y-m-d', $monthEnd)); + + [$yearStart, $yearEnd] = EnergyChartCalculator::rangeBounds( + 'year', + '2026-09-27', + '2026-09-01', + '2026-09-02' + ); + self::assertSame('2026-01-01', date('Y-m-d', $yearStart)); + self::assertSame('2027-01-01', date('Y-m-d', $yearEnd)); + } + + public function testCustomRangeIncludesCompleteEndDateAcrossDstChange(): void + { + [$start, $end] = EnergyChartCalculator::rangeBounds( + 'custom', + '2026-03-29', + '2026-03-28', + '2026-03-30' + ); + + self::assertSame('2026-03-28 00:00', date('Y-m-d H:i', $start)); + self::assertSame('2026-03-31 00:00', date('Y-m-d H:i', $end)); + } + + public function testInvalidCustomRangeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + EnergyChartCalculator::rangeBounds( + 'custom', + '2026-09-27', + '2026-09-28', + '2026-09-27' + ); + } + + public function testMetricsUseDerivedOrExplicitConsumption(): void + { + $derived = EnergyChartCalculator::metrics(20.0, 5.0, 3.0); + self::assertSame(18.0, $derived['Hausverbrauch']); + self::assertSame(15.0, $derived['Eigenverbrauch']); + self::assertSame(75.0, $derived['Eigenverbrauchsquote']); + self::assertEqualsWithDelta(83.3333, $derived['Autarkiegrad'], 0.001); + + $explicit = EnergyChartCalculator::metrics(20.0, 5.0, 3.0, 16.0); + self::assertSame(16.0, $explicit['Hausverbrauch']); + self::assertSame(13.0, $explicit['Eigenverbrauch']); + self::assertSame(65.0, $explicit['Eigenverbrauchsquote']); + self::assertSame(81.25, $explicit['Autarkiegrad']); + } + + public function testColorsAreValidatedAndNormalized(): void + { + self::assertSame( + '#2F80ED', + EnergyChartCalculator::normalizeColor('#2f80ed', '#000000') + ); + self::assertSame( + '#22A06B', + EnergyChartCalculator::normalizeColor('transparent', '#22a06b') + ); + } +} diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php index 6d833dd..1af28a0 100644 --- a/tests/Symcon/manifest.php +++ b/tests/Symcon/manifest.php @@ -3,6 +3,12 @@ declare(strict_types=1); return [ + 'Energiediagramm' => [ + 'file' => __DIR__ . '/modules/Energiediagramm.php', + 'paths' => [ + 'Energiediagramm/*', + ], + ], 'ShellyModul' => [ 'file' => __DIR__ . '/modules/ShellyModul.php', 'paths' => [ diff --git a/tests/Symcon/modules/Energiediagramm.php b/tests/Symcon/modules/Energiediagramm.php new file mode 100644 index 0000000..a3f89cc --- /dev/null +++ b/tests/Symcon/modules/Energiediagramm.php @@ -0,0 +1,61 @@ +createVariable(2, 'Produktion', 1200.0); + $feedInId = $test->createVariable(2, 'Einspeisung', 350.0); + $gridId = $test->createVariable(2, 'Netzbezug', 180.0); + + $moduleId = $test->createInstance( + '{F73B8F8D-5E36-4B23-B379-4632A517A1D6}', + 'Energiediagramm', + [ + 'ProduktionZaehlerID' => $productionId, + 'EinspeisungZaehlerID' => $feedInId, + 'NetzbezugZaehlerID' => $gridId, + 'Standardzeitraum' => 'month', + 'FarbeEigenverbrauch' => '#1259A7', + 'FarbeAutarkie' => '#178A55', + ] + ); + + $test->runCase('Instanz und Eigenschaften werden angelegt', static function ( + TestContext $test + ) use ($moduleId): void { + $test->assertInstanceStatus($moduleId); + $test->assertSame('month', IPS_GetProperty($moduleId, 'Standardzeitraum')); + $test->assertSame('#1259A7', IPS_GetProperty($moduleId, 'FarbeEigenverbrauch')); + $test->assertSame('#178A55', IPS_GetProperty($moduleId, 'FarbeAutarkie')); + }); + + $test->runCase('Konfigurationsformular ist gültiges JSON', static function ( + TestContext $test + ) use ($moduleId): void { + $form = json_decode( + IPS_GetConfigurationForm($moduleId), + true, + 512, + JSON_THROW_ON_ERROR + ); + $test->assertTrue(is_array($form)); + }); + + $test->runCase('Zeitraum und Datumsbereich werden angenommen', static function ( + TestContext $test + ) use ($moduleId): void { + IPS_RequestAction($moduleId, 'SetRange', 'year'); + IPS_RequestAction( + $moduleId, + 'SetCustomRange', + json_encode( + ['start' => '2026-01-01', 'end' => '2026-01-31'], + JSON_THROW_ON_ERROR + ) + ); + IPS_RequestAction($moduleId, 'Refresh', true); + $test->assertSame(102, (int) IPS_GetInstance($moduleId)['InstanceStatus']); + }); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php index 6ce37cb..5965c83 100644 --- a/tests/SymconTestContractTest.php +++ b/tests/SymconTestContractTest.php @@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void { self::assertSame( - ['ShellyModul', 'Verbrauchskostenreport'], + ['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'], $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) ); }