@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixUtils\Energiediagramm;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class EnergyChartCalculator
|
||||||
|
{
|
||||||
|
public const RANGES = ['day', 'week', 'month', 'year', 'total', 'custom'];
|
||||||
|
|
||||||
|
public static function isValidDate(string $date): bool
|
||||||
|
{
|
||||||
|
$value = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||||
|
$errors = DateTimeImmutable::getLastErrors();
|
||||||
|
|
||||||
|
return $value !== false
|
||||||
|
&& ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
|
||||||
|
&& $value->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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
<div id="energy-app">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="field">
|
||||||
|
<span>Zeitraum</span>
|
||||||
|
<select id="range">
|
||||||
|
<option value="day">Tag</option>
|
||||||
|
<option value="week">Woche</option>
|
||||||
|
<option value="month">Monat</option>
|
||||||
|
<option value="year">Jahr</option>
|
||||||
|
<option value="total">Gesamt</option>
|
||||||
|
<option value="custom">Datumsbereich</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label id="date-field" class="field">
|
||||||
|
<span>Datum</span>
|
||||||
|
<input id="date" type="date">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="navigation" class="button-row" aria-label="Zeitraum wechseln">
|
||||||
|
<button id="previous" class="icon-button" type="button" title="Vorheriger Zeitraum" aria-label="Vorheriger Zeitraum">‹</button>
|
||||||
|
<button id="today" type="button">Heute</button>
|
||||||
|
<button id="next" class="icon-button" type="button" title="Nächster Zeitraum" aria-label="Nächster Zeitraum">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="custom-range" class="custom-range" hidden>
|
||||||
|
<label class="field">
|
||||||
|
<span>Von</span>
|
||||||
|
<input id="custom-start" type="date">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Bis</span>
|
||||||
|
<input id="custom-end" type="date">
|
||||||
|
</label>
|
||||||
|
<button id="apply-range" type="button">Anzeigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="period" class="period" aria-live="polite"></div>
|
||||||
|
<div id="message" class="message" role="status" hidden></div>
|
||||||
|
|
||||||
|
<div id="summary" class="summary"></div>
|
||||||
|
|
||||||
|
<div id="charts" class="charts">
|
||||||
|
<section class="chart" aria-labelledby="self-consumption-title">
|
||||||
|
<h3 id="self-consumption-title">Eigenverbrauchsquote</h3>
|
||||||
|
<div id="self-consumption-chart" class="donut"></div>
|
||||||
|
</section>
|
||||||
|
<section class="chart" aria-labelledby="autarky-title">
|
||||||
|
<h3 id="autarky-title">Autarkiegrad</h3>
|
||||||
|
<div id="autarky-chart" class="donut"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="error" class="error" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#energy-app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.custom-range,
|
||||||
|
.button-row,
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
min-width: 128px;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
min-height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: inherit;
|
||||||
|
background: rgba(127, 127, 127, 0.08);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period {
|
||||||
|
min-height: 22px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-top: 1px solid rgba(127, 127, 127, 0.35);
|
||||||
|
border-bottom: 1px solid rgba(127, 127, 127, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
align-items: stretch;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-width: 130px;
|
||||||
|
flex: 1 1 150px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 2px solid rgba(127, 127, 127, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span,
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts.narrow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut svg {
|
||||||
|
display: block;
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-track {
|
||||||
|
stroke: rgba(127, 127, 127, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-value {
|
||||||
|
transition: stroke-dasharray 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-label {
|
||||||
|
fill: currentColor;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 750;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
min-height: 18px;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #d14343;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
#energy-app {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.custom-range {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
flex: 1 1 145px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const initialState = __ENELIX_INITIAL_STATE__;
|
||||||
|
const app = document.getElementById('energy-app');
|
||||||
|
const range = document.getElementById('range');
|
||||||
|
const date = document.getElementById('date');
|
||||||
|
const dateField = document.getElementById('date-field');
|
||||||
|
const navigation = document.getElementById('navigation');
|
||||||
|
const previous = document.getElementById('previous');
|
||||||
|
const today = document.getElementById('today');
|
||||||
|
const next = document.getElementById('next');
|
||||||
|
const customRange = document.getElementById('custom-range');
|
||||||
|
const customStart = document.getElementById('custom-start');
|
||||||
|
const customEnd = document.getElementById('custom-end');
|
||||||
|
const applyRange = document.getElementById('apply-range');
|
||||||
|
const period = document.getElementById('period');
|
||||||
|
const message = document.getElementById('message');
|
||||||
|
const summary = document.getElementById('summary');
|
||||||
|
const charts = document.getElementById('charts');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const selfConsumptionChart = document.getElementById('self-consumption-chart');
|
||||||
|
const autarkyChart = document.getElementById('autarky-chart');
|
||||||
|
|
||||||
|
function send(action, value) {
|
||||||
|
if (typeof requestAction !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
requestAction(action, value);
|
||||||
|
error.textContent = '';
|
||||||
|
} catch (exception) {
|
||||||
|
error.textContent = exception && exception.message
|
||||||
|
? exception.message
|
||||||
|
: String(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEnergy(value) {
|
||||||
|
return asNumber(value).toLocaleString('de-CH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}) + ' kWh';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
const parts = String(value).split('-');
|
||||||
|
return parts.length === 3
|
||||||
|
? parts[2] + '.' + parts[1] + '.' + parts[0]
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLabel(data) {
|
||||||
|
if (data.range === 'total') {
|
||||||
|
return 'Gesamter Zählerstand';
|
||||||
|
}
|
||||||
|
if (data.range === 'custom') {
|
||||||
|
return formatDate(data.customStart) + ' bis ' + formatDate(data.customEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(asNumber(data.tStart) * 1000);
|
||||||
|
const end = new Date((asNumber(data.tEnd) * 1000) - 1000);
|
||||||
|
if (data.range === 'day') {
|
||||||
|
return 'Tag: ' + start.toLocaleDateString('de-CH');
|
||||||
|
}
|
||||||
|
if (data.range === 'week') {
|
||||||
|
return 'Woche: ' + start.toLocaleDateString('de-CH')
|
||||||
|
+ ' bis ' + end.toLocaleDateString('de-CH');
|
||||||
|
}
|
||||||
|
if (data.range === 'month') {
|
||||||
|
return 'Monat: ' + start.toLocaleDateString('de-CH', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.range === 'year') {
|
||||||
|
return 'Jahr: ' + start.getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function donut(target, percent, color) {
|
||||||
|
const value = Math.max(0, Math.min(100, asNumber(percent)));
|
||||||
|
const radius = 58;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const filled = (value / 100) * circumference;
|
||||||
|
|
||||||
|
target.innerHTML = [
|
||||||
|
'<svg viewBox="0 0 176 176" role="img" aria-label="'
|
||||||
|
+ value.toFixed(1) + ' Prozent">',
|
||||||
|
'<circle class="donut-track" cx="88" cy="88" r="' + radius
|
||||||
|
+ '" fill="none" stroke-width="18"></circle>',
|
||||||
|
'<circle class="donut-value" cx="88" cy="88" r="' + radius
|
||||||
|
+ '" fill="none" stroke="' + color + '" stroke-width="18"'
|
||||||
|
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"'
|
||||||
|
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>',
|
||||||
|
'<text class="donut-label" x="88" y="91">'
|
||||||
|
+ value.toFixed(1) + '%</text>',
|
||||||
|
'</svg>'
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value) {
|
||||||
|
return '<div class="metric"><span>' + label + '</span><strong>'
|
||||||
|
+ formatEnergy(value) + '</strong></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
range.value = data.range || 'day';
|
||||||
|
date.value = data.date || '';
|
||||||
|
customStart.value = data.customStart || '';
|
||||||
|
customEnd.value = data.customEnd || '';
|
||||||
|
const currentMode = range.value;
|
||||||
|
const custom = currentMode === 'custom';
|
||||||
|
const total = currentMode === 'total';
|
||||||
|
|
||||||
|
dateField.hidden = custom || total;
|
||||||
|
navigation.hidden = custom || total;
|
||||||
|
customRange.hidden = !custom;
|
||||||
|
period.textContent = periodLabel(data);
|
||||||
|
|
||||||
|
const values = data.values || {};
|
||||||
|
summary.innerHTML = [
|
||||||
|
metric('Produktion', values.Produktion),
|
||||||
|
metric('Hausverbrauch', values.Hausverbrauch),
|
||||||
|
metric('Netzbezug', values.Netzbezug),
|
||||||
|
metric('Einspeisung', values.Einspeisung)
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const colors = data.colors || {};
|
||||||
|
donut(
|
||||||
|
selfConsumptionChart,
|
||||||
|
values.Eigenverbrauchsquote,
|
||||||
|
colors.selfConsumption || '#2F80ED'
|
||||||
|
);
|
||||||
|
donut(
|
||||||
|
autarkyChart,
|
||||||
|
values.Autarkiegrad,
|
||||||
|
colors.autarky || '#22A06B'
|
||||||
|
);
|
||||||
|
|
||||||
|
message.hidden = data.hasData !== false;
|
||||||
|
message.textContent = data.hasData === false
|
||||||
|
? (data.noDataHint || 'Keine Archivwerte verfügbar.')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.handleMessage = function (data) {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch (exception) {
|
||||||
|
error.textContent = 'Die Aktualisierung enthält ungültige Daten.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
range.addEventListener('change', function () {
|
||||||
|
send('SetRange', range.value);
|
||||||
|
});
|
||||||
|
date.addEventListener('change', function () {
|
||||||
|
send('SetDate', date.value);
|
||||||
|
});
|
||||||
|
previous.addEventListener('click', function () {
|
||||||
|
send('Prev', true);
|
||||||
|
});
|
||||||
|
next.addEventListener('click', function () {
|
||||||
|
send('Next', true);
|
||||||
|
});
|
||||||
|
today.addEventListener('click', function () {
|
||||||
|
send('Today', true);
|
||||||
|
});
|
||||||
|
applyRange.addEventListener('click', function () {
|
||||||
|
send('SetCustomRange', JSON.stringify({
|
||||||
|
start: customStart.value,
|
||||||
|
end: customEnd.value
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentDate = [
|
||||||
|
now.getFullYear(),
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getDate()).padStart(2, '0')
|
||||||
|
].join('-');
|
||||||
|
date.max = currentDate;
|
||||||
|
customStart.max = currentDate;
|
||||||
|
customEnd.max = currentDate;
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
const observer = new ResizeObserver(function (entries) {
|
||||||
|
const width = entries[0] ? entries[0].contentRect.width : app.clientWidth;
|
||||||
|
charts.classList.toggle('narrow', width < 520);
|
||||||
|
});
|
||||||
|
observer.observe(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (!document.hidden) {
|
||||||
|
send('Refresh', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render(initialState);
|
||||||
|
window.setTimeout(function () {
|
||||||
|
send('Refresh', true);
|
||||||
|
}, 0);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/libs/EnergyChartCalculator.php';
|
||||||
|
|
||||||
|
use Belevo\EnelixUtils\Energiediagramm\EnergyChartCalculator;
|
||||||
|
|
||||||
|
class Energiediagramm extends IPSModule
|
||||||
|
{
|
||||||
|
private const ARCHIVE_MODULE_ID = '{43192F0B-135B-4CE7-A0A7-1475603F3060}';
|
||||||
|
private const ATTR_RANGE = 'SelectedRange';
|
||||||
|
private const ATTR_DATE = 'SelectedDate';
|
||||||
|
private const ATTR_CUSTOM_START = 'CustomStart';
|
||||||
|
private const ATTR_CUSTOM_END = 'CustomEnd';
|
||||||
|
private const COLOR_SELF_CONSUMPTION = '#2F80ED';
|
||||||
|
private const COLOR_AUTARKY = '#22A06B';
|
||||||
|
|
||||||
|
public function Create(): void
|
||||||
|
{
|
||||||
|
parent::Create();
|
||||||
|
|
||||||
|
$this->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 '<div style="padding:12px">Darstellung nicht verfügbar.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,16 @@ Unabhaengige Zusatzmodule fuer IP-Symcon. Dieses Repository ist nicht vom Enelix
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Das Repository befindet sich im Aufbau. Verbrauchskostenreport und Shelly
|
Das Repository befindet sich im Aufbau. Verbrauchskostenreport,
|
||||||
Modul sind als installierbare IP-Symcon-Module enthalten; die weiteren Module
|
Energiediagramm und Shelly Modul sind als installierbare IP-Symcon-Module
|
||||||
sind derzeit als Diskussionsentwürfe dokumentiert.
|
enthalten; die weiteren Module sind derzeit als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
- Verbrauchskostenreport (implementiert)
|
- Verbrauchskostenreport (implementiert)
|
||||||
- Virtuelle Batterie
|
- Virtuelle Batterie
|
||||||
- CC100 Hardware
|
- CC100 Hardware
|
||||||
- Energiediagramm
|
- Energiediagramm (implementiert)
|
||||||
- VGT-Schnittstelle
|
- VGT-Schnittstelle
|
||||||
- Shelly Modul (implementiert)
|
- Shelly Modul (implementiert)
|
||||||
|
|
||||||
@@ -56,9 +56,10 @@ da IP-Symcon sonst URL, Branch und Aktualisierungsstatus nicht verwalten kann.
|
|||||||
## Aktualisierung und Prüfung
|
## Aktualisierung und Prüfung
|
||||||
|
|
||||||
Aktualisierungen werden ausschließlich über die IP-Symcon-Modulverwaltung
|
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:
|
Module geladen sein:
|
||||||
|
|
||||||
|
- `Energiediagramm`
|
||||||
- `Shelly Modul`
|
- `Shelly Modul`
|
||||||
- `Verbrauchskostenreport`
|
- `Verbrauchskostenreport`
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,59 @@
|
|||||||
# Energiediagramm
|
# 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
|
Das Modul adaptiert `Energy_Pie` als unabhaengiges Enelix-Utils-Modul. Es
|
||||||
dieselbe Darstellung wie die bestehende Aktualisieren-Aktion.
|
wertet fortlaufende Energiezaehler aus dem IP-Symcon-Archiv aus und zeigt die
|
||||||
|
Energiebilanz sowie Eigenverbrauchsquote und Autarkiegrad in einer individuellen
|
||||||
## Variablen
|
HTML-SDK-Darstellung.
|
||||||
|
|
||||||
Keine klassischen Pflichtvariablen. Energiewerte und Auswahl erscheinen in der
|
|
||||||
individuellen Darstellung; Zeitraum und Datum bleiben intern gespeichert.
|
|
||||||
|
|
||||||
## Properties
|
## Properties
|
||||||
|
|
||||||
| Ident | Typ | Standard / Beschreibung |
|
| Ident | Typ | Standard / Beschreibung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ProduktionZaehlerID` | Integer | `0`; alt `VarProduction`. |
|
| `ProduktionZaehlerID` | Integer | Pflichtquelle fuer produzierte Energie |
|
||||||
| `VerbrauchZaehlerID` | Integer | `0`; alt `VarConsumption`. |
|
| `EinspeisungZaehlerID` | Integer | Pflichtquelle fuer eingespeiste Energie |
|
||||||
| `EinspeisungZaehlerID` | Integer | `0`; alt `VarFeedIn`. |
|
| `NetzbezugZaehlerID` | Integer | Pflichtquelle fuer bezogene Energie |
|
||||||
| `NetzbezugZaehlerID` | Integer | `0`; alt `VarGrid`. |
|
| `VerbrauchZaehlerID` | Integer | Optionale direkte Hausverbrauchsquelle |
|
||||||
| `Zaehlerfaktor` | Float | `1`; auf kWh normieren. |
|
| `Zaehlerfaktor` | Float | `1.0`; gemeinsamer Faktor auf kWh |
|
||||||
| `Standardzeitraum` | String/Auswahl | `Tag`; Tag, Woche, Monat, Jahr oder Gesamt. |
|
| `Standardzeitraum` | String | `day`; Tag, Woche, Monat, Jahr oder Gesamt |
|
||||||
| `Aktualisierungsintervall` | Integer | `60` s. |
|
| `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.
|
Die Visualisierung bietet Tag, Woche, Monat, Jahr, Gesamt und Datumsbereich.
|
||||||
- Fehlende Historie darf nicht als echter Nullverbrauch dargestellt werden.
|
Der freie Datumsbereich umfasst Start- und Enddatum jeweils vollstaendig. Fuer
|
||||||
- Aktualisierung beim Öffnen in den verfügbaren Symcon-Visualisierungen testen.
|
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 |
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Modulübersicht Enelix Utils
|
# Modulübersicht Enelix Utils
|
||||||
|
|
||||||
> Status: Verbrauchskostenreport und Shelly Modul sind implementiert. Die
|
> Status: Verbrauchskostenreport, Energiediagramm und Shelly Modul sind
|
||||||
> übrigen Module sind als Diskussionsentwürfe dokumentiert.
|
> implementiert. Die übrigen Module sind als Diskussionsentwürfe dokumentiert.
|
||||||
|
|
||||||
| Modul | Herkunft | EMS-Abhängigkeit |
|
| Modul | Herkunft | EMS-Abhängigkeit |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
|
| [Verbrauchskostenreport](Verbrauchskostenreport/README.md) | Kosten- und Verbrauchsauswertung | Keine |
|
||||||
| [Virtuelle Batterie](Virtuelle-Batterie/README.md) | Bat_EV_SDL_V4 | Optionaler Vertrag, keine Code-Abhängigkeit |
|
| [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 |
|
| [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 |
|
| [VGT-Schnittstelle](VGT-Schnittstelle/README.md) | MQTTPVSDL + MQTTBatterySDL | Optional konfigurierbar |
|
||||||
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
|
| [Shelly Modul](Shelly-Modul/README.md) | Shelly_Parser_MQTT | Keine |
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ werden, wenn sie vor dem Lauf nicht existierten.
|
|||||||
- `single`: genau die als Auswahl übergebenen Module
|
- `single`: genau die als Auswahl übergebenen Module
|
||||||
- `affected`: die durch geänderte Pfade ermittelten 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.
|
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class DokumentationsstrukturTest extends TestCase
|
|||||||
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
|
'Verbrauchskostenreport' => ['Verbrauchskostenreport', 'Status: Implementiert'],
|
||||||
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
|
'Virtuelle Batterie' => ['Virtuelle-Batterie', 'Status: Diskussionsentwurf'],
|
||||||
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
|
'CC100 Hardware' => ['CC100-Hardware', 'Status: Diskussionsentwurf'],
|
||||||
'Energiediagramm' => ['Energiediagramm', 'Status: Diskussionsentwurf'],
|
'Energiediagramm' => ['Energiediagramm', 'Status: Implementiert'],
|
||||||
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
'VGT-Schnittstelle' => ['VGT-Schnittstelle', 'Status: Diskussionsentwurf'],
|
||||||
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
|
'Shelly-Modul' => ['Shelly-Modul', 'Status: Implementiert'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixUtils\Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EnergiediagrammModulstrukturTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testModuleMetadataAndFormExposeRequiredConfiguration(): void
|
||||||
|
{
|
||||||
|
$module = json_decode(
|
||||||
|
file_get_contents(__DIR__ . '/../Energiediagramm/module.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
$form = json_decode(
|
||||||
|
file_get_contents(__DIR__ . '/../Energiediagramm/form.json'),
|
||||||
|
true,
|
||||||
|
512,
|
||||||
|
JSON_THROW_ON_ERROR
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('{F73B8F8D-5E36-4B23-B379-4632A517A1D6}', $module['id']);
|
||||||
|
self::assertSame('Energiediagramm', $module['name']);
|
||||||
|
self::assertSame('ENELIXENERGY', $module['prefix']);
|
||||||
|
|
||||||
|
$serialized = json_encode($form, JSON_THROW_ON_ERROR);
|
||||||
|
foreach ([
|
||||||
|
'ProduktionZaehlerID',
|
||||||
|
'EinspeisungZaehlerID',
|
||||||
|
'NetzbezugZaehlerID',
|
||||||
|
'VerbrauchZaehlerID',
|
||||||
|
'Standardzeitraum',
|
||||||
|
'FarbeEigenverbrauch',
|
||||||
|
'FarbeAutarkie',
|
||||||
|
] as $property) {
|
||||||
|
self::assertStringContainsString($property, $serialized);
|
||||||
|
}
|
||||||
|
self::assertSame(2, substr_count($serialized, 'SelectColor'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHtmlStartsWithDataAndRespondsToAvailableWidth(): void
|
||||||
|
{
|
||||||
|
$html = file_get_contents(__DIR__ . '/../Energiediagramm/module.html');
|
||||||
|
self::assertNotFalse($html);
|
||||||
|
self::assertStringContainsString('__ENELIX_INITIAL_STATE__', $html);
|
||||||
|
self::assertStringContainsString('ResizeObserver', $html);
|
||||||
|
self::assertStringContainsString('grid-template-columns: minmax(0, 1fr)', $html);
|
||||||
|
self::assertStringContainsString('SetCustomRange', $html);
|
||||||
|
self::assertStringNotContainsString("addEventListener('load'", $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testModuleEmbedsInitialPayloadForTileAndPopup(): void
|
||||||
|
{
|
||||||
|
$module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php');
|
||||||
|
self::assertNotFalse($module);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
"str_replace('__ENELIX_INITIAL_STATE__'",
|
||||||
|
$module
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('GetVisualizationPopup', $module);
|
||||||
|
self::assertStringContainsString('Standardzeitraum', $module);
|
||||||
|
self::assertStringContainsString('CustomStart', $module);
|
||||||
|
self::assertStringContainsString('CustomEnd', $module);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Belevo\EnelixUtils\Tests;
|
||||||
|
|
||||||
|
use Belevo\EnelixUtils\Energiediagramm\EnergyChartCalculator;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../Energiediagramm/libs/EnergyChartCalculator.php';
|
||||||
|
|
||||||
|
final class EnergiediagrammTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $timezone;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'Energiediagramm' => [
|
||||||
|
'file' => __DIR__ . '/modules/Energiediagramm.php',
|
||||||
|
'paths' => [
|
||||||
|
'Energiediagramm/*',
|
||||||
|
],
|
||||||
|
],
|
||||||
'ShellyModul' => [
|
'ShellyModul' => [
|
||||||
'file' => __DIR__ . '/modules/ShellyModul.php',
|
'file' => __DIR__ . '/modules/ShellyModul.php',
|
||||||
'paths' => [
|
'paths' => [
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Belevo\EnelixUtils\SymconTest\TestContext;
|
||||||
|
|
||||||
|
return static function (TestContext $test): void {
|
||||||
|
$productionId = $test->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']);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -58,7 +58,7 @@ final class SymconTestContractTest extends TestCase
|
|||||||
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['ShellyModul', 'Verbrauchskostenreport'],
|
['Energiediagramm', 'ShellyModul', 'Verbrauchskostenreport'],
|
||||||
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
$this->resolveAffectedModules(['tests/Symcon/TestContext.php'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user