fix(energy-pie): represent indeterminate shares and preserve prepared styling
Tests / test (push) Successful in 50s

This commit is contained in:
dh
2026-10-06 17:40:42 +00:00
parent c5f924bd19
commit 5b6125d086
10 changed files with 241 additions and 27 deletions
+6
View File
@@ -2,6 +2,12 @@
Alle wesentlichen Aenderungen an Enelix Utils werden in dieser Datei dokumentiert. Alle wesentlichen Aenderungen an Enelix Utils werden in dieser Datei dokumentiert.
## Unreleased
- Energiediagramm zeigt wieder den urspruenglichen dunklen Energy-Pie-Hintergrund
mit blauen und violetten Farbverlaeufen, auch bei hellem Visualisierungsthema.
Bedienelemente bleiben lesbar; Konfiguration und Berechnungen sind unveraendert.
## 0.1 Build 1 - Erste Beta - 2026-09-29 ## 0.1 Build 1 - Erste Beta - 2026-09-29
### Enthalten ### Enthalten
+8 -5
View File
@@ -31,11 +31,14 @@ dezimalen Farbwerte.
## Darstellung ## Darstellung
Der Inhalt beginnt unterhalb des von Symcon eingeblendeten Kachelkopfs, damit Der Inhalt beginnt unterhalb des von Symcon eingeblendeten Kachelkopfs, damit
Zeitraum und Datum vollstaendig bedienbar bleiben. Hintergrund, Text und Zeitraum und Datum vollstaendig bedienbar bleiben. Der urspruengliche Energy-Pie-
Bedienelemente verwenden die von IP-Symcon bereitgestellten Kachel- und Hintergrund mit dunkler Grundfarbe und blauen/violetten Farbverlaeufen bleibt
Textfarben. Auch geoeffnete Auswahlfelder sowie native Datumselemente passen sich auch in einem hellen Visualisierungsthema sichtbar. Text, Auswahlfelder und
automatisch an ein helles oder dunkles Visualisierungsthema an. Beim Oeffnen und native Datumselemente verwenden innerhalb des Moduls dazu passende helle
beim erneuten Sichtbarwerden wird die Darstellung aktualisiert. Das Schrift und dunkle Bedienflaechen. Die konfigurierten Diagrammfarben bleiben
unveraendert; eine Konfigurationsmigration ist nicht erforderlich. Die
Hintergrundebene blockiert weder Klicks noch Scrollen. Beim Oeffnen und beim
erneuten Sichtbarwerden wird die Darstellung aktualisiert. Das
Diagrammraster wechselt bei geringer Breite automatisch von zwei Spalten auf Diagrammraster wechselt bei geringer Breite automatisch von zwei Spalten auf
eine Spalte. eine Spalte.
+18
View File
@@ -0,0 +1,18 @@
# Nicht eindeutig bestimmbare Energieanteile
Die optionale Property `EnergieanteileBerechenbar` ist standardmaessig true und
erhaelt das bisherige Verhalten. Setzt ein aufrufender Manager sie auf false,
bleiben gemessene Summen erhalten. Eigenverbrauch und zugehoerige Anteile werden
als null geliefert und als nicht bestimmbar dargestellt, niemals als 0 Prozent.
Anwendungsfall: Netzbezug enthaelt SDL, die Hauslast wurde um SDL bereinigt.
Ohne eindeutige Zuordnung laesst sich der solare Anteil nicht exakt bestimmen.
Die Policy gehoert in EMS; Utils implementiert nur die generische Darstellung.
Individuelle Quellen, Farben und Energiezaehler werden nicht veraendert. Die
bereits vorbereitete Wiederherstellung des dunklen Pie-Hintergrunds bleibt
erhalten. Utils vor einem Manager mit dieser neuen Property aktualisieren.
Validierung: 58 PHPUnit-Tests und vier zusaetzliche JavaScript-Visualisierungstests
erfolgreich; unbekannte, alte numerische und spaeter wieder verfuegbare Anteile
sind abgedeckt.
+9
View File
@@ -32,6 +32,15 @@
"maximum": 1000000, "maximum": 1000000,
"digits": 6 "digits": 6
}, },
{
"type": "CheckBox",
"name": "EnergieanteileBerechenbar",
"caption": "Eigenverbrauch und Autarkie aus gemeinsamer Energiebilanz berechnen"
},
{
"type": "Label",
"caption": "Bei abweichenden Messgrenzen oder nicht zuordenbaren Speicherfluessen ausschalten. Energiesummen bleiben sichtbar; Anteile werden als nicht bestimmbar gekennzeichnet."
},
{ {
"type": "Select", "type": "Select",
"name": "Standardzeitraum", "name": "Standardzeitraum",
+10 -9
View File
@@ -82,16 +82,17 @@ final class EnergyChartCalculator
* Einspeisung: float, * Einspeisung: float,
* Netzbezug: float, * Netzbezug: float,
* Hausverbrauch: float, * Hausverbrauch: float,
* Eigenverbrauch: float, * Eigenverbrauch: float|null,
* Eigenverbrauchsquote: float, * Eigenverbrauchsquote: float|null,
* Autarkiegrad: float * Autarkiegrad: float|null
* } * }
*/ */
public static function metrics( public static function metrics(
float $production, float $production,
float $feedIn, float $feedIn,
float $grid, float $grid,
?float $consumption = null ?float $consumption = null,
bool $sharesAvailable = true
): array { ): array {
$production = max(0.0, $production); $production = max(0.0, $production);
$feedIn = max(0.0, $feedIn); $feedIn = max(0.0, $feedIn);
@@ -106,13 +107,13 @@ final class EnergyChartCalculator
'Einspeisung' => $feedIn, 'Einspeisung' => $feedIn,
'Netzbezug' => $grid, 'Netzbezug' => $grid,
'Hausverbrauch' => $house, 'Hausverbrauch' => $house,
'Eigenverbrauch' => $selfConsumption, 'Eigenverbrauch' => $sharesAvailable ? $selfConsumption : null,
'Eigenverbrauchsquote' => $production > 0.0 'Eigenverbrauchsquote' => !$sharesAvailable ? null : ($production > 0.0
? min(100.0, ($selfConsumption / $production) * 100.0) ? min(100.0, ($selfConsumption / $production) * 100.0)
: 0.0, : 0.0),
'Autarkiegrad' => $house > 0.0 'Autarkiegrad' => !$sharesAvailable ? null : ($house > 0.0
? min(100.0, ($selfConsumption / $house) * 100.0) ? min(100.0, ($selfConsumption / $house) * 100.0)
: 0.0, : 0.0),
]; ];
} }
+45 -11
View File
@@ -63,6 +63,7 @@
html, html,
body { body {
min-width: 0; min-width: 0;
height: 100%;
min-height: 100%; min-height: 100%;
margin: 0; margin: 0;
color: var(--content-color, inherit); color: var(--content-color, inherit);
@@ -70,6 +71,11 @@
} }
#energy-app { #energy-app {
--card-color: #121216;
--content-color: #f5f5f7;
position: relative;
isolation: isolate;
color-scheme: dark;
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -80,6 +86,20 @@
background-color: var(--card-color, transparent); background-color: var(--card-color, transparent);
} }
/* Keep the original Energy Pie colors behind the scrollable controls. */
#energy-app::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background:
radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%),
radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%),
radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%);
filter: blur(18px);
pointer-events: none;
}
.toolbar, .toolbar,
.custom-range, .custom-range,
.button-row, .button-row,
@@ -228,6 +248,11 @@
dominant-baseline: middle; dominant-baseline: middle;
} }
.donut-label.unavailable {
font-size: 14px;
font-weight: 600;
}
.error { .error {
min-height: 18px; min-height: 18px;
margin-top: 8px; margin-top: 8px;
@@ -366,6 +391,8 @@
} }
function donut(target, percent, color) { function donut(target, percent, color) {
const available = percent !== null && percent !== undefined
&& Number.isFinite(Number(percent));
const value = Math.max(0, Math.min(100, asNumber(percent))); const value = Math.max(0, Math.min(100, asNumber(percent)));
const radius = 58; const radius = 58;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@@ -373,15 +400,17 @@
target.innerHTML = [ target.innerHTML = [
'<svg viewBox="0 0 176 176" role="img" aria-label="' '<svg viewBox="0 0 176 176" role="img" aria-label="'
+ value.toFixed(1) + ' Prozent">', + (available ? value.toFixed(1) + ' Prozent' : 'Nicht bestimmbar') + '">',
'<circle class="donut-track" cx="88" cy="88" r="' + radius '<circle class="donut-track" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke-width="18"></circle>', + '" fill="none" stroke-width="18"></circle>',
'<circle class="donut-value" cx="88" cy="88" r="' + radius available ? '<circle class="donut-value" cx="88" cy="88" r="' + radius
+ '" fill="none" stroke="' + color + '" stroke-width="18"' + '" fill="none" stroke="' + color + '" stroke-width="18"'
+ ' stroke-linecap="round" transform="rotate(-90 88 88)"' + ' stroke-linecap="round" transform="rotate(-90 88 88)"'
+ ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>', + ' stroke-dasharray="' + filled + ' ' + circumference + '"></circle>' : '',
'<text class="donut-label" x="88" y="91">' available ? '<text class="donut-label" x="88" y="91">'
+ value.toFixed(1) + '%</text>', + value.toFixed(1) + '%</text>'
: '<text class="donut-label unavailable" x="88" y="80">'
+ '<tspan x="88">Nicht</tspan><tspan x="88" dy="20">bestimmbar</tspan></text>',
'</svg>' '</svg>'
].join(''); ].join('');
} }
@@ -421,19 +450,24 @@
const colors = data.colors || {}; const colors = data.colors || {};
donut( donut(
selfConsumptionChart, selfConsumptionChart,
values.Eigenverbrauchsquote, data.sharesAvailable === false ? null : values.Eigenverbrauchsquote,
colors.selfConsumption || '#2F80ED' colors.selfConsumption || '#2F80ED'
); );
donut( donut(
autarkyChart, autarkyChart,
values.Autarkiegrad, data.sharesAvailable === false ? null : values.Autarkiegrad,
colors.autarky || '#22A06B' colors.autarky || '#22A06B'
); );
message.hidden = data.hasData !== false; const hints = [];
message.textContent = data.hasData === false if (data.hasData === false) {
? (data.noDataHint || 'Keine Archivwerte verfügbar.') hints.push(data.noDataHint || 'Keine Archivwerte verfügbar.');
: ''; }
if (data.sharesAvailable === false) {
hints.push(data.attributionHint || 'Eigenverbrauch und Autarkie sind nicht eindeutig bestimmbar.');
}
message.hidden = hints.length === 0;
message.textContent = hints.join(' ');
} }
window.handleMessage = function (data) { window.handleMessage = function (data) {
+8 -2
View File
@@ -25,6 +25,7 @@ class Energiediagramm extends IPSModule
$this->RegisterPropertyInteger('NetzbezugZaehlerID', 0); $this->RegisterPropertyInteger('NetzbezugZaehlerID', 0);
$this->RegisterPropertyInteger('VerbrauchZaehlerID', 0); $this->RegisterPropertyInteger('VerbrauchZaehlerID', 0);
$this->RegisterPropertyFloat('Zaehlerfaktor', 1.0); $this->RegisterPropertyFloat('Zaehlerfaktor', 1.0);
$this->RegisterPropertyBoolean('EnergieanteileBerechenbar', true);
$this->RegisterPropertyString('Standardzeitraum', 'day'); $this->RegisterPropertyString('Standardzeitraum', 'day');
$this->RegisterPropertyInteger('Aktualisierungsintervall', 60); $this->RegisterPropertyInteger('Aktualisierungsintervall', 60);
$this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION); $this->RegisterPropertyString('FarbeEigenverbrauch', self::COLOR_SELF_CONSUMPTION);
@@ -253,6 +254,7 @@ class Energiediagramm extends IPSModule
$customStart, $customStart,
$customEnd $customEnd
); );
$sharesAvailable = $this->ReadPropertyBoolean('EnergieanteileBerechenbar');
$payload = [ $payload = [
'range' => $range, 'range' => $range,
@@ -263,6 +265,9 @@ class Energiediagramm extends IPSModule
'tEnd' => $end, 'tEnd' => $end,
'hasData' => false, 'hasData' => false,
'noDataHint' => 'Energiezähler konfigurieren.', 'noDataHint' => 'Energiezähler konfigurieren.',
'sharesAvailable' => $sharesAvailable,
'attributionHint' => $sharesAvailable ? ''
: 'Eigenverbrauch und Autarkie sind bei unterschiedlichen Messgrenzen nicht eindeutig bestimmbar. Die Energiesummen bleiben separat sichtbar.',
'colors' => [ 'colors' => [
'selfConsumption' => EnergyChartCalculator::normalizeColor( 'selfConsumption' => EnergyChartCalculator::normalizeColor(
$this->ReadPropertyString('FarbeEigenverbrauch'), $this->ReadPropertyString('FarbeEigenverbrauch'),
@@ -273,7 +278,7 @@ class Energiediagramm extends IPSModule
self::COLOR_AUTARKY self::COLOR_AUTARKY
), ),
], ],
'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0), 'values' => EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, $sharesAvailable),
]; ];
if (!$this->configurationIsValid()) { if (!$this->configurationIsValid()) {
@@ -329,7 +334,8 @@ class Energiediagramm extends IPSModule
$production['value'] * $factor, $production['value'] * $factor,
$feedIn['value'] * $factor, $feedIn['value'] * $factor,
$grid['value'] * $factor, $grid['value'] * $factor,
$consumption $consumption,
$sharesAvailable
); );
if (!$payload['hasData']) { if (!$payload['hasData']) {
@@ -33,6 +33,7 @@ final class EnergiediagrammModulstrukturTest extends TestCase
'EinspeisungZaehlerID', 'EinspeisungZaehlerID',
'NetzbezugZaehlerID', 'NetzbezugZaehlerID',
'VerbrauchZaehlerID', 'VerbrauchZaehlerID',
'EnergieanteileBerechenbar',
'Standardzeitraum', 'Standardzeitraum',
'FarbeEigenverbrauch', 'FarbeEigenverbrauch',
'FarbeAutarkie', 'FarbeAutarkie',
@@ -58,6 +59,30 @@ final class EnergiediagrammModulstrukturTest extends TestCase
self::assertStringNotContainsString("addEventListener('load'", $html); self::assertStringNotContainsString("addEventListener('load'", $html);
} }
public function testOriginalEnergyPieBackgroundStaysReadableAndNonInteractive(): void
{
$html = file_get_contents(__DIR__ . '/../Energiediagramm/module.html');
self::assertNotFalse($html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--card-color: #121216;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*--content-color: #f5f5f7;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*color-scheme: dark;/s', $html);
self::assertMatchesRegularExpression('/#energy-app\\s*\\{[^}]*isolation: isolate;/s', $html);
self::assertSame(1, preg_match('/#energy-app::before\\s*\\{([^}]+)\\}/s', $html, $background));
foreach ([
'inset: 0',
'z-index: -1',
'radial-gradient(520px 420px at 18% 18%, rgba(99, 179, 255, 0.42), transparent 60%)',
'radial-gradient(560px 460px at 82% 28%, rgba(168, 85, 247, 0.38), transparent 62%)',
'radial-gradient(620px 520px at 55% 85%, rgba(99, 179, 255, 0.26), transparent 64%)',
'filter: blur(18px)',
'pointer-events: none',
] as $style) {
self::assertStringContainsString($style, $background[1]);
}
self::assertStringContainsString('overflow: auto', $html);
self::assertStringNotContainsString('touch-action: none', $html);
}
public function testModuleEmbedsInitialPayloadAndEnablesSupportedVisualization(): void public function testModuleEmbedsInitialPayloadAndEnablesSupportedVisualization(): void
{ {
$module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php'); $module = file_get_contents(__DIR__ . '/../Energiediagramm/module.php');
+24
View File
@@ -127,4 +127,28 @@ final class EnergiediagrammTest extends TestCase
EnergyChartCalculator::normalizeColor('-1', '#22a06b') EnergyChartCalculator::normalizeColor('-1', '#22a06b')
); );
} }
public function testMixedMeasurementBoundariesKeepTotalsWithoutInventingShares(): void
{
$values = EnergyChartCalculator::metrics(10.0, 0.0, 10.0, 5.0, false);
self::assertSame(10.0, $values['Produktion']);
self::assertSame(0.0, $values['Einspeisung']);
self::assertSame(10.0, $values['Netzbezug']);
self::assertSame(5.0, $values['Hausverbrauch']);
self::assertNull($values['Eigenverbrauch']);
self::assertNull($values['Eigenverbrauchsquote']);
self::assertNull($values['Autarkiegrad']);
}
public function testUnavailableSharesRemainNullWithZeroEnergy(): void
{
$unavailable = EnergyChartCalculator::metrics(0.0, 0.0, 0.0, null, false);
self::assertNull($unavailable['Eigenverbrauchsquote']);
self::assertNull($unavailable['Autarkiegrad']);
$available = EnergyChartCalculator::metrics(0.0, 0.0, 0.0);
self::assertSame(0.0, $available['Eigenverbrauchsquote']);
self::assertSame(0.0, $available['Autarkiegrad']);
}
} }
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function visualization(initialState) {
const elements = new Map();
const element = id => {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '', textContent: '', value: '', hidden: false,
addEventListener() {}, classList: { toggle() {} }
});
}
return elements.get(id);
};
const window = { setTimeout() {} };
const document = {
getElementById: element,
documentElement: { style: {} },
addEventListener() {}
};
const html = fs.readFileSync(path.join(__dirname, '../Energiediagramm/module.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace('__ENELIX_INITIAL_STATE__', JSON.stringify(initialState));
vm.runInNewContext(script, {
window, document,
getComputedStyle: () => ({ getPropertyValue: () => '' })
});
return { element, window };
}
function payload(overrides = {}) {
return {
range: 'day', date: '2026-10-06', hasData: true,
values: {
Produktion: 10, Hausverbrauch: 5, Netzbezug: 10, Einspeisung: 0,
Eigenverbrauchsquote: null, Autarkiegrad: null
},
...overrides
};
}
test('unavailable shares show neutral rings while preserving energy totals', () => {
const ui = visualization(payload({ sharesAvailable: false, attributionHint: 'Different measurement boundaries.' }));
for (const id of ['self-consumption-chart', 'autarky-chart']) {
const html = ui.element(id).innerHTML;
assert.match(html, /Nicht bestimmbar/);
assert.doesNotMatch(html, /donut-value|0\.0%/);
}
assert.match(ui.element('summary').innerHTML, /Produktion/);
assert.match(ui.element('summary').innerHTML, /10\.00 kWh/);
assert.match(ui.element('summary').innerHTML, /5\.00 kWh/);
assert.equal(ui.element('message').hidden, false);
assert.equal(ui.element('message').textContent, 'Different measurement boundaries.');
});
test('unavailable flag cannot display stale numeric percentages', () => {
const state = payload({ sharesAvailable: false });
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 50;
const ui = visualization(state);
assert.doesNotMatch(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.doesNotMatch(ui.element('autarky-chart').innerHTML, /50\.0%/);
});
test('legacy numeric percentages and later recovery remain compatible', () => {
const ui = visualization(payload({ sharesAvailable: false }));
const state = payload();
state.values.Eigenverbrauchsquote = 75;
state.values.Autarkiegrad = 81.25;
ui.window.handleMessage(JSON.stringify(state));
assert.match(ui.element('self-consumption-chart').innerHTML, /75\.0%/);
assert.match(ui.element('autarky-chart').innerHTML, /81\.3%/);
assert.equal(ui.element('message').hidden, true);
});
test('missing archive values do not hide the attribution limitation', () => {
const ui = visualization(payload({
hasData: false, noDataHint: 'No archive data.',
sharesAvailable: false, attributionHint: 'Different measurement boundaries.'
}));
assert.equal(ui.element('message').textContent,
'No archive data. Different measurement boundaries.');
});