From 2c5b9eddc4e88a56552a44d1b0d426e5bc19c1c9 Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 23 Sep 2026 16:36:56 +0200 Subject: [PATCH 1/8] Sankey home power calculation --- .../charts/sankeyChart/SankeyChart.vue | 22 +++- .../charts/sankeyChart/energy-allocation.ts | 107 ++++++++++++++---- .../charts/sankeyChart/useSankeyData.ts | 56 ++++++--- 3 files changed, 149 insertions(+), 36 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue index 0eb67a4bc6..49a83105c4 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue @@ -26,6 +26,7 @@ import type { SankeyDataPoint } from 'chartjs-chart-sankey'; import { useSankeyData } from './useSankeyData'; import { useSankeyHover } from './useSankeyHover'; import { createFlowLabels } from './sankey-flow-labels'; +import { NODE_HOUSE, NODE_HOUSE_REST } from './energy-allocation'; // A typed sankey component (like vue-chartjs's built-in Line/Bar) so the // `data`/`options` props are fixed to 'sankey' instead of the whole ChartType @@ -87,17 +88,21 @@ const chartData = computed>(() => { // Chart.js calls later) is what makes this recompute on every hover change. const flowColor = focusColors(); - const { edges, nodes, sources, sinks } = allocation.value; + const { edges, nodes, sources, sinks, houseParts } = allocation.value; const labels: Record = {}; const columns: Record = {}; for (const node of nodes) { labels[node.id] = node.label; - columns[node.id] = sources.some((source) => source.id === node.id) ? 0 : 1; + columns[node.id] = node.column; } // Assign a per-column priority from node power. // Sources descending (largest on top). - // Sinks ascending (largest on the bottom). + // Sinks ascending (largest on the bottom). When the household splits into a + // third column it goes on top instead, level with its parts, so the split + // does not have to cross the other sinks. + // House parts descending, the remainder last. + const houseSplit = houseParts.length > 0; const priority: Record = {}; [...sources] .sort((a, b) => b.power - a.power) @@ -106,6 +111,17 @@ const chartData = computed>(() => { }); [...sinks] .sort((a, b) => a.power - b.power) + .forEach((node, index) => { + priority[node.id] = houseSplit && node.id === NODE_HOUSE ? -1 : index; + }); + [...houseParts] + .sort((a, b) => + a.id === NODE_HOUSE_REST + ? 1 + : b.id === NODE_HOUSE_REST + ? -1 + : b.power - a.power, + ) .forEach((node, index) => { priority[node.id] = index; }); diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts index 6a256e6127..b77a885b8a 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts @@ -3,6 +3,13 @@ export const NODE_GRID = 'grid'; export const NODE_PV = 'pv'; export const NODE_BATTERY = 'battery'; export const NODE_HOUSE = 'house'; +export const NODE_HOUSE_REST = 'house_rest'; +export const NODE_NOT_IN_HOME = 'not_in_home'; + +/** Column of each node in the diagram, left to right. */ +export const COLUMN_SOURCE = 0; +export const COLUMN_SINK = 1; +export const COLUMN_HOUSE_PART = 2; /** * Raw signed power per component, using the same sign conventions as the @@ -11,6 +18,10 @@ export const NODE_HOUSE = 'house'; * pv : - production (source)| + consumption (rare) * battery : + charging (sink) | - discharging (source) * cp/consumer power: + drawing (sink) | - feeding back, e.g. V2G (source) + * + * Consumers flagged `inHouse` are counted in the home consumption. They are + * not drawn as sinks of their own but as parts of the household node, which + * splits into them and the remaining household consumption in a third column. */ export interface EnergyFlowInput { grid: number; @@ -18,6 +29,11 @@ export interface EnergyFlowInput { battery: number; chargePoints: DynamicNodeInput[]; consumers: DynamicNodeInput[]; + /** + * Power not counted in the home consumption that is no consumer. + * + drawing (sink) | - feeding (source). + */ + notInHomeOther?: number; /** * Hybrid inverter/battery pairs. A hybrid battery charges from its inverter's * PV directly on the shared DC bus, before the remaining PV mixes with grid @@ -35,12 +51,14 @@ export interface DynamicNodeInput { id: string; label: string; power: number; + inHouse?: boolean; } export interface FlowNode { id: string; label: string; power: number; + column: number; } export interface FlowEdge { @@ -55,6 +73,8 @@ export interface AllocationResult { nodes: FlowNode[]; sources: FlowNode[]; sinks: FlowNode[]; + /** Consumers counted in the home consumption plus the remainder. */ + houseParts: FlowNode[]; totalSources: number; totalSinks: number; imbalance: number; @@ -70,55 +90,96 @@ const MIN_EDGE_WATTS = 1; function classifyNodes(input: EnergyFlowInput): { sources: FlowNode[]; sinks: FlowNode[]; + houseParts: FlowNode[]; } { const sources: FlowNode[] = []; const sinks: FlowNode[] = []; + const houseParts: FlowNode[] = []; + const source = (id: string, label: string, power: number) => + sources.push({ id, label, power, column: COLUMN_SOURCE }); + const sink = (id: string, label: string, power: number) => + sinks.push({ id, label, power, column: COLUMN_SINK }); if (input.grid > 0) { - sources.push({ id: NODE_GRID, label: 'Netz', power: input.grid }); + source(NODE_GRID, 'Netz', input.grid); } else if (input.grid < 0) { - sinks.push({ id: NODE_GRID, label: 'Netz', power: -input.grid }); + sink(NODE_GRID, 'Netz', -input.grid); } if (input.pv < 0) { - sources.push({ id: NODE_PV, label: 'PV', power: -input.pv }); + source(NODE_PV, 'PV', -input.pv); } if (input.battery > 0) { - sinks.push({ id: NODE_BATTERY, label: 'Speicher', power: input.battery }); + sink(NODE_BATTERY, 'Speicher', input.battery); } else if (input.battery < 0) { - sources.push({ - id: NODE_BATTERY, - label: 'Speicher', - power: -input.battery, - }); + source(NODE_BATTERY, 'Speicher', -input.battery); } for (const cp of input.chargePoints) { if (cp.power > 0) { - sinks.push({ id: cp.id, label: cp.label, power: cp.power }); + sink(cp.id, cp.label, cp.power); } else if (cp.power < 0) { - sources.push({ id: cp.id, label: cp.label, power: -cp.power }); + source(cp.id, cp.label, -cp.power); } } for (const consumer of input.consumers) { - if (consumer.power > 0) { - sinks.push({ id: consumer.id, label: consumer.label, power: consumer.power }); + if (consumer.power > 0 && consumer.inHouse) { + houseParts.push({ + id: consumer.id, + label: consumer.label, + power: consumer.power, + column: COLUMN_HOUSE_PART, + }); + } else if (consumer.power > 0) { + sink(consumer.id, consumer.label, consumer.power); } else if (consumer.power < 0) { - sources.push({ id: consumer.id, label: consumer.label, power: -consumer.power }); + source(consumer.id, consumer.label, -consumer.power); } } - // Household consumption = balancing residual. + const notInHomeOther = input.notInHomeOther ?? 0; + if (notInHomeOther > 0) { + sink(NODE_NOT_IN_HOME, 'Sonstige', notInHomeOther); + } else if (notInHomeOther < 0) { + source(NODE_NOT_IN_HOME, 'Sonstige', -notInHomeOther); + } + + // Household consumption = balancing residual. It includes the consumers + // counted in the home consumption, as they are not sinks of their own. const totalSources = sumPower(sources); const otherSinks = sumPower(sinks); const house = Math.max(0, totalSources - otherSinks); if (house > 0) { - sinks.push({ id: NODE_HOUSE, label: 'Hausverbrauch', power: house }); + sink(NODE_HOUSE, 'Hausverbrauch', house); } - return { sources, sinks }; + return { sources, sinks, houseParts: splitHouse(house, houseParts) }; +} + +/** + * Split the household node into the consumers counted in it and the rest. + * Measurement noise can make those consumers add up to more than the + * residual; they are then scaled down so the household node still balances. + */ +function splitHouse(house: number, consumers: FlowNode[]): FlowNode[] { + if (house <= 0 || consumers.length === 0) { + return []; + } + const consumerTotal = sumPower(consumers); + const scale = Math.min(1, house / consumerTotal); + const parts = consumers.map((node) => ({ ...node, power: node.power * scale })); + const rest = house - consumerTotal * scale; + if (rest >= MIN_EDGE_WATTS) { + parts.push({ + id: NODE_HOUSE_REST, + label: 'Sonstiger Hausverbrauch', + power: rest, + column: COLUMN_HOUSE_PART, + }); + } + return parts; } function sumPower(nodes: FlowNode[]): number { @@ -179,7 +240,7 @@ function mergeEdges(edges: FlowEdge[]): FlowEdge[] { * With no hybrid pairs this reduces to plain uniform proportional. */ export function allocate(input: EnergyFlowInput): AllocationResult { - const { sources, sinks } = classifyNodes(input); + const { sources, sinks, houseParts } = classifyNodes(input); const totalSources = sumPower(sources); const totalSinks = sumPower(sinks); @@ -235,11 +296,19 @@ export function allocate(input: EnergyFlowInput): AllocationResult { } } + // Second stage: household -> the consumers counted in it and the rest. + for (const part of houseParts) { + if (part.power >= MIN_EDGE_WATTS) { + edges.push({ from: NODE_HOUSE, to: part.id, flow: part.power }); + } + } + return { edges: mergeEdges(edges), - nodes: [...sources, ...sinks], + nodes: [...sources, ...sinks, ...houseParts], sources, sinks, + houseParts, totalSources, totalSinks, imbalance: totalSources - totalSinks, diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts index 4d554bc830..0c6bdc2c54 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts @@ -13,6 +13,7 @@ import { const GROUP_THRESHOLD = 3; const CP_GROUP_ID = 'cp_group'; const CONSUMER_GROUP_ID = 'consumer_group'; +const CONSUMER_HOUSE_GROUP_ID = 'consumer_house_group'; const CONSUMER_ID_PREFIX = 'consumer'; export function useSankeyData() { @@ -31,20 +32,27 @@ export function useSankeyData() { ), ); - const consumers = computed(() => - groupNodes( - mqttStore.consumerIds.map((id) => ({ - id: `${CONSUMER_ID_PREFIX}${id}`, - label: mqttStore.consumerName(id) || `Verbraucher ${id}`, - power: num(mqttStore.consumerPower(id, 'value')), - })), - { - threshold: GROUP_THRESHOLD, - id: CONSUMER_GROUP_ID, - label: 'Verbraucher', - }, - ), - ); + // Consumers counted in the home consumption are drawn as parts of the + // household node, the others as sinks of their own. Group each set on its + // own so the two never collapse into one node. + const consumers = computed(() => { + const inHouse = mqttStore.inHomeConsumption.consumerIds; + const nodes = mqttStore.consumerIds.map((id) => ({ + id: `${CONSUMER_ID_PREFIX}${id}`, + label: mqttStore.consumerName(id) || `Verbraucher ${id}`, + power: num(mqttStore.consumerPower(id, 'value')), + inHouse: inHouse.includes(id), + })); + const group = (inHouseSet: boolean, id: string) => + groupNodes( + nodes.filter((node) => node.inHouse === inHouseSet), + { threshold: GROUP_THRESHOLD, id, label: 'Verbraucher' }, + ).map((node) => ({ ...node, inHouse: inHouseSet })); + return [ + ...group(true, CONSUMER_HOUSE_GROUP_ID), + ...group(false, CONSUMER_GROUP_ID), + ]; + }); // Hybrid inverter/battery pairs: how much of each hybrid battery's charge is // covered by its own inverter's PV on the DC bus. pvPowerIndividual reports @@ -56,6 +64,21 @@ export function useSankeyData() { })), ); + // What is not counted in the home consumption and is no consumer: the + // sub-counters set to "Nein". The backend publishes only the sum, so the + // consumers, which are sinks of their own, are taken out of it. + const notInHomeOther = computed(() => { + const { consumerIds, counterIds } = mqttStore.notInHomeConsumption; + if (counterIds.length === 0) { + return 0; + } + const consumerPower = consumerIds.reduce( + (total, id) => total + num(mqttStore.consumerPower(id, 'value')), + 0, + ); + return num(mqttStore.notInHomeConsumptionPower('value')) - consumerPower; + }); + const allocation = computed(() => allocate({ grid: num(mqttStore.counterPower('value')), @@ -65,6 +88,7 @@ export function useSankeyData() { : 0, chargePoints: chargePoints.value, consumers: consumers.value, + notInHomeOther: notInHomeOther.value, hybrid: hybrid.value, }), ); @@ -81,10 +105,14 @@ export function useSankeyData() { case 'battery': return cssVar('--q-battery-stroke'); case 'house': + case 'house_rest': return cssVar('--q-home-stroke'); + case 'not_in_home': + return cssVar('--q-secondary-counter-stroke'); case CP_GROUP_ID: return cssVar('--q-charge-point-stroke'); case CONSUMER_GROUP_ID: + case CONSUMER_HOUSE_GROUP_ID: return cssVar('--q-consumer'); } if (id.startsWith('cp')) { From 03d84833abead295cbaa491bbaab9450b5ef17a1 Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 23 Sep 2026 16:48:44 +0200 Subject: [PATCH 2/8] Sankey legend for small screens --- .../source/src/components/ChartCarousel.vue | 5 +- .../charts/sankeyChart/SankeyChart.vue | 96 ++++++++++++++----- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue b/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue index 4d499f7259..7a43f3007b 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue @@ -23,7 +23,10 @@ @@ -44,10 +57,11 @@ const ChartjsSankey = createTypedChart('sankey', [ defineOptions({ name: 'SankeyChart' }); -// Accepted for parity with the other carousel charts; not used here. +// The legend is only shown in compact mode, where node names are hidden. defineProps<{ showLegend?: boolean }>(); const $q = useQuasar(); +const compact = computed(() => $q.screen.lt.sm); const { allocation, colorForNode, labelColor } = useSankeyData(); const hasFlows = computed(() => allocation.value.edges.length > 0); @@ -79,29 +93,14 @@ const flowLabels = createFlowLabels({ }); const chartPlugins = [hoverPlugin, flowLabels.plugin]; -const chartData = computed>(() => { - // Reference the theme so a light/dark toggle rebuilds data and re-resolves - // the CSS color variables. - void $q.dark.isActive; - - // Reading the hover state here (rather than in the color callbacks, which - // Chart.js calls later) is what makes this recompute on every hover change. - const flowColor = focusColors(); - - const { edges, nodes, sources, sinks, houseParts } = allocation.value; - const labels: Record = {}; - const columns: Record = {}; - for (const node of nodes) { - labels[node.id] = node.label; - columns[node.id] = node.column; - } - - // Assign a per-column priority from node power. - // Sources descending (largest on top). - // Sinks ascending (largest on the bottom). When the household splits into a - // third column it goes on top instead, level with its parts, so the split - // does not have to cross the other sinks. - // House parts descending, the remainder last. +// Assign a per-column priority from node power. +// Sources descending (largest on top). +// Sinks ascending (largest on the bottom). When the household splits into a +// third column it goes on top instead, level with its parts, so the split +// does not have to cross the other sinks. +// House parts descending, the remainder last. +const nodePriority = computed(() => { + const { sources, sinks, houseParts } = allocation.value; const houseSplit = houseParts.length > 0; const priority: Record = {}; [...sources] @@ -125,6 +124,23 @@ const chartData = computed>(() => { .forEach((node, index) => { priority[node.id] = index; }); + return priority; +}); + +const chartData = computed>(() => { + // Reference the theme so a light/dark toggle rebuilds data and re-resolves + // the CSS color variables. + void $q.dark.isActive; + // Reading the hover state here (rather than in the color callbacks, which + // Chart.js calls later) is what makes this recompute on every hover change. + const flowColor = focusColors(); + const { edges, nodes } = allocation.value; + const labels: Record = {}; + const columns: Record = {}; + for (const node of nodes) { + labels[node.id] = node.label; + columns[node.id] = node.column; + } const textColor = labelColor(); @@ -139,7 +155,7 @@ const chartData = computed>(() => { })), labels, column: columns, - priority, + priority: nodePriority.value, colorFrom: (ctx) => flowColor( (ctx.raw as SankeyDataPoint | undefined)?.from ?? '', @@ -153,7 +169,7 @@ const chartData = computed>(() => { colorMode: 'gradient', borderWidth: 0, color: textColor, - nodeLabels: { color: textColor }, + nodeLabels: { color: textColor, display: !compact.value }, nodeWidth: 16, nodePadding: 12, }, @@ -161,6 +177,21 @@ const chartData = computed>(() => { }; }); +const legendItems = computed(() => { + void $q.dark.isActive; + const priority = nodePriority.value; + return [...allocation.value.nodes] + .sort( + (a, b) => + a.column - b.column || (priority[a.id] ?? 0) - (priority[b.id] ?? 0), + ) + .map((node) => ({ + id: node.id, + label: node.label, + color: colorForNode(node.id), + })); +}); + const chartOptions = computed>(() => ({ responsive: true, maintainAspectRatio: false, @@ -200,6 +231,19 @@ const chartOptions = computed>(() => ({ height: 100% !important; } +.legend-wrapper { + flex: 0 0 auto; + gap: 2px 10px; +} + +.legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + margin-right: 4px; + flex: 0 0 auto; +} + .sankey-empty { height: 100%; display: flex; From e774772c1ecb077ae116e4e78599d7ca28156a72 Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 7 Oct 2026 16:49:51 +0200 Subject: [PATCH 3/8] chart legend only when Sankey has three columns --- .../koala/source/src/components/ChartCarousel.vue | 4 +++- .../src/components/charts/sankeyChart/SankeyChart.vue | 4 ++-- .../src/components/charts/sankeyChart/useSankeyData.ts | 10 +++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue b/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue index 7a43f3007b..d19916fdae 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChartCarousel.vue @@ -25,7 +25,7 @@ { localDataStore.toggleLegendVisibility(); }; const legendVisible = computed(() => localDataStore.legendVisible); +const { compact: sankeyCompact } = useSankeyData(); const fullscreen = ref(false); const carouselKey = computed(() => diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue index b4601f4708..e1281f6c8d 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/SankeyChart.vue @@ -58,11 +58,11 @@ const ChartjsSankey = createTypedChart('sankey', [ defineOptions({ name: 'SankeyChart' }); // The legend is only shown in compact mode, where node names are hidden. +// When chart has three columns. defineProps<{ showLegend?: boolean }>(); const $q = useQuasar(); -const compact = computed(() => $q.screen.lt.sm); -const { allocation, colorForNode, labelColor } = useSankeyData(); +const { allocation, compact, colorForNode, labelColor } = useSankeyData(); const hasFlows = computed(() => allocation.value.edges.length > 0); diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts index 0c6bdc2c54..0caa69497d 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts @@ -2,6 +2,7 @@ * Store adapter for the live Sankey diagram. */ import { computed } from 'vue'; +import { useQuasar } from 'quasar'; import { useMqttStore } from 'src/stores/mqtt-store'; import { allocate, @@ -18,6 +19,7 @@ const CONSUMER_ID_PREFIX = 'consumer'; export function useSankeyData() { const mqttStore = useMqttStore(); + const $q = useQuasar(); const num = (value: unknown): number => Number(value) || 0; @@ -93,6 +95,12 @@ export function useSankeyData() { }), ); + // A small screen has no room for node names once the household splits into + // a third column. The names are then hidden and listed in a legend instead. + const compact = computed(() => { + return $q.screen.lt.sm && allocation.value.houseParts.length > 0; + }); + /** * Resolve the display color for a node id. */ @@ -135,7 +143,7 @@ export function useSankeyData() { return getComputedStyle(document.body).color || '#000000'; }; - return { allocation, colorForNode, labelColor }; + return { allocation, compact, colorForNode, labelColor }; } /** From e2ecc8f00a90a6ad5fa59330c469b83ef89f4766 Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 7 Oct 2026 17:06:25 +0200 Subject: [PATCH 4/8] formatting --- .../charts/sankeyChart/energy-allocation.ts | 5 ++++- .../charts/sankeyChart/useSankeyData.ts | 15 +++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts index b77a885b8a..1ed9ce92bc 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts @@ -169,7 +169,10 @@ function splitHouse(house: number, consumers: FlowNode[]): FlowNode[] { } const consumerTotal = sumPower(consumers); const scale = Math.min(1, house / consumerTotal); - const parts = consumers.map((node) => ({ ...node, power: node.power * scale })); + const parts = consumers.map((node) => ({ + ...node, + power: node.power * scale, + })); const rest = house - consumerTotal * scale; if (rest >= MIN_EDGE_WATTS) { parts.push({ diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts index 0caa69497d..ff73ce4830 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts @@ -61,8 +61,14 @@ export function useSankeyData() { // production as negative; batteryPower reports charging as positive. const hybrid = computed(() => mqttStore.hybridInverters.map(({ inverterId, batteryId }) => ({ - inverterPv: Math.max(0, -num(mqttStore.pvPowerIndividual(inverterId, 'value'))), - batteryCharge: Math.max(0, num(mqttStore.batteryPower(batteryId, 'value'))), + inverterPv: Math.max( + 0, + -num(mqttStore.pvPowerIndividual(inverterId, 'value')), + ), + batteryCharge: Math.max( + 0, + num(mqttStore.batteryPower(batteryId, 'value')), + ), })), ); @@ -125,7 +131,9 @@ export function useSankeyData() { } if (id.startsWith('cp')) { const cpId = Number(id.slice(2)); - return mqttStore.chargePointColor(cpId) || cssVar('--q-charge-point-stroke'); + return ( + mqttStore.chargePointColor(cpId) || cssVar('--q-charge-point-stroke') + ); } if (id.startsWith(CONSUMER_ID_PREFIX)) { const consumerId = Number(id.slice(CONSUMER_ID_PREFIX.length)); @@ -134,7 +142,6 @@ export function useSankeyData() { return cssVar('--q-vehicle-stroke'); }; - //Node-label color for dark mode / light mode. const labelColor = (): string => { if (typeof document === 'undefined') { From ee346a2626de2f9ae7c3a41d2384662148e12f4e Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 14 Oct 2026 10:37:42 +0200 Subject: [PATCH 5/8] name change --- .../src/components/charts/sankeyChart/energy-allocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts index 1ed9ce92bc..4e6083a606 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts @@ -141,7 +141,7 @@ function classifyNodes(input: EnergyFlowInput): { const notInHomeOther = input.notInHomeOther ?? 0; if (notInHomeOther > 0) { - sink(NODE_NOT_IN_HOME, 'Sonstige', notInHomeOther); + sink(NODE_NOT_IN_HOME, 'Zähler', notInHomeOther); } else if (notInHomeOther < 0) { source(NODE_NOT_IN_HOME, 'Sonstige', -notInHomeOther); } From 67b85b2caee52a010b422c57152d7f1bebd06a09 Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Wed, 14 Oct 2026 15:51:14 +0200 Subject: [PATCH 6/8] name change --- .../src/components/charts/sankeyChart/energy-allocation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts index 4e6083a606..da5ec81d45 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts @@ -143,7 +143,7 @@ function classifyNodes(input: EnergyFlowInput): { if (notInHomeOther > 0) { sink(NODE_NOT_IN_HOME, 'Zähler', notInHomeOther); } else if (notInHomeOther < 0) { - source(NODE_NOT_IN_HOME, 'Sonstige', -notInHomeOther); + source(NODE_NOT_IN_HOME, 'Zähler', -notInHomeOther); } // Household consumption = balancing residual. It includes the consumers From 897f32f9d29e7566bef248c5d234430b59bed80c Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Mon, 19 Oct 2026 10:02:57 +0200 Subject: [PATCH 7/8] update store - counter local power --- .../koala/source/src/stores/mqtt-store.ts | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts index b245f09246..17ba8a3dbb 100644 --- a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts +++ b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts @@ -4267,6 +4267,32 @@ export const useMqttStore = defineStore('mqtt', () => { }; }); + /** + * Get the counter's own share of power: its power minus the power of the + * components directly below it. identified by counterId + * @param returnType type of return value, 'textValue', 'value', 'scaledValue', 'scaledUnit' or 'object' + * @param counterId counter ID + * @returns string | number | ValueObject | undefined + */ + const counterLocalPower = computed(() => { + return (returnType: string = 'textValue', counterId?: number) => { + const id = counterId; + let localPower = undefined; + if (id !== undefined) { + localPower = getValue.value(`openWB/counter/${id}/get/local_power`) as + number | undefined; + } + const valueObject = getValueObject.value(localPower); + if (returnType in valueObject) { + return valueObject[returnType as keyof ValueObject]; + } + if (returnType == 'object') { + return valueObject; + } + console.error('returnType not found!', returnType, localPower); + }; + }); + /** * Get daily counter energy imported identified by root grid counter in component hierarchy or counterId * @param returnType type of return value, 'textValue', 'value', 'scaledValue', 'scaledUnit' or 'object' @@ -4424,6 +4450,7 @@ export const useMqttStore = defineStore('mqtt', () => { const ids = () => ({ consumerIds: [] as number[], counterIds: [] as number[], + counterIdsWithChildren: [] as number[], }); const result = { inHome: ids(), notInHome: ids() }; function walk( @@ -4442,10 +4469,19 @@ export const useMqttStore = defineStore('mqtt', () => { AUTO, ) as string | undefined; const mode = own && own !== AUTO ? own : parentMode; + let bucket; if (mode === NOT_HOME) { - result.notInHome.counterIds.push(node.id); - } else if (!isRoot) { - result.inHome.counterIds.push(node.id); + bucket = result.notInHome; + } else if (isRoot) { + bucket = undefined; + } else { + bucket = result.inHome; + } + if (bucket) { + bucket.counterIds.push(node.id); + if (node.children?.length) { + bucket.counterIdsWithChildren.push(node.id); + } } walk(node.children, mode, false); } else if (node.type === 'consumer') { @@ -4490,6 +4526,34 @@ export const useMqttStore = defineStore('mqtt', () => { () => homeConsumptionAssignment.value.notInHome, ); + // own share in W below which a counter with components below it is hidden + // (threshold for showing in the home consumption details) + const COUNTER_OWN_SHARE_THRESHOLD = 50; + + /** + * Get the counters to list in the home consumption details. A counter with + * components below it is left out if its own share is negligible, as it + * then only measures those components. + * @returns object with the keys inHome and notInHome, each holding counter ids + */ + const shownCounterIds = computed(() => { + const shown = (bucket: { + counterIds: number[]; + counterIdsWithChildren: number[]; + }) => { + // a counter that only measures the components below it is hidden + const hidden = (id: number) => + bucket.counterIdsWithChildren.includes(id) && + Math.abs(Number(counterLocalPower.value('value', id) ?? 0)) < + COUNTER_OWN_SHARE_THRESHOLD; + return bucket.counterIds.filter((id) => !hidden(id)); + }; + return { + inHome: shown(inHomeConsumption.value), + notInHome: shown(notInHomeConsumption.value), + }; + }); + /** * Get the summed power of all consumers and counters that are not counted * in the home consumption. @@ -4899,6 +4963,7 @@ export const useMqttStore = defineStore('mqtt', () => { componentName, gridComponentColor, counterPower, + counterLocalPower, counterDailyImported, counterDailyExported, // Home data @@ -4906,6 +4971,7 @@ export const useMqttStore = defineStore('mqtt', () => { homeDailyYield, inHomeConsumption, notInHomeConsumption, + shownCounterIds, notInHomeConsumptionPower, unmeasuredHomeConsumptionPower, // PV data From 3025c7e6dce1ea599c2442103d5eabb9616bbf5c Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Mon, 19 Oct 2026 15:49:19 +0200 Subject: [PATCH 8/8] Add counter nodes to sankey diagram --- .../charts/sankeyChart/energy-allocation.ts | 73 +++++++--------- .../charts/sankeyChart/useSankeyData.ts | 87 ++++++++++++------- 2 files changed, 90 insertions(+), 70 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts index da5ec81d45..ee90200bcd 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/energy-allocation.ts @@ -1,10 +1,9 @@ -/** Fixed node ids. Chargepoints/consumers use dynamic ids (see input). */ +/** Fixed node ids. Chargepoints/consumers/counters use dynamic ids (see input). */ export const NODE_GRID = 'grid'; export const NODE_PV = 'pv'; export const NODE_BATTERY = 'battery'; export const NODE_HOUSE = 'house'; export const NODE_HOUSE_REST = 'house_rest'; -export const NODE_NOT_IN_HOME = 'not_in_home'; /** Column of each node in the diagram, left to right. */ export const COLUMN_SOURCE = 0; @@ -17,11 +16,12 @@ export const COLUMN_HOUSE_PART = 2; * grid : + import (source) | - export (sink) * pv : - production (source)| + consumption (rare) * battery : + charging (sink) | - discharging (source) - * cp/consumer power: + drawing (sink) | - feeding back, e.g. V2G (source) + * cp/consumer/counter power: + drawing (sink) | - feeding back, e.g. V2G (source) * - * Consumers flagged `inHouse` are counted in the home consumption. They are - * not drawn as sinks of their own but as parts of the household node, which - * splits into them and the remaining household consumption in a third column. + * Consumers and counters flagged `inHouse` are counted in the home + * consumption. They are not drawn as sinks of their own but as parts of the + * household node, which splits into them and the remaining household + * consumption in a third column. */ export interface EnergyFlowInput { grid: number; @@ -29,11 +29,10 @@ export interface EnergyFlowInput { battery: number; chargePoints: DynamicNodeInput[]; consumers: DynamicNodeInput[]; - /** - * Power not counted in the home consumption that is no consumer. - * + drawing (sink) | - feeding (source). - */ - notInHomeOther?: number; + //Secondary counters with their own share of power: their power minus the + //components below them, which are nodes of their own. + + counters?: DynamicNodeInput[]; /** * Hybrid inverter/battery pairs. A hybrid battery charges from its inverter's * PV directly on the shared DC bus, before the remaining PV mixes with grid @@ -73,7 +72,7 @@ export interface AllocationResult { nodes: FlowNode[]; sources: FlowNode[]; sinks: FlowNode[]; - /** Consumers counted in the home consumption plus the remainder. */ + /** Consumers and counters counted in the home consumption plus the remainder. */ houseParts: FlowNode[]; totalSources: number; totalSinks: number; @@ -124,30 +123,24 @@ function classifyNodes(input: EnergyFlowInput): { } } - for (const consumer of input.consumers) { - if (consumer.power > 0 && consumer.inHouse) { + for (const load of [...input.consumers, ...(input.counters ?? [])]) { + if (load.power > 0 && load.inHouse) { houseParts.push({ - id: consumer.id, - label: consumer.label, - power: consumer.power, + id: load.id, + label: load.label, + power: load.power, column: COLUMN_HOUSE_PART, }); - } else if (consumer.power > 0) { - sink(consumer.id, consumer.label, consumer.power); - } else if (consumer.power < 0) { - source(consumer.id, consumer.label, -consumer.power); + } else if (load.power > 0) { + sink(load.id, load.label, load.power); + } else if (load.power < 0) { + source(load.id, load.label, -load.power); } } - const notInHomeOther = input.notInHomeOther ?? 0; - if (notInHomeOther > 0) { - sink(NODE_NOT_IN_HOME, 'Zähler', notInHomeOther); - } else if (notInHomeOther < 0) { - source(NODE_NOT_IN_HOME, 'Zähler', -notInHomeOther); - } - - // Household consumption = balancing residual. It includes the consumers - // counted in the home consumption, as they are not sinks of their own. + // Household consumption = balancing residual. It includes the consumers and + // counters counted in the home consumption, as they are not sinks of their + // own. const totalSources = sumPower(sources); const otherSinks = sumPower(sinks); const house = Math.max(0, totalSources - otherSinks); @@ -159,21 +152,21 @@ function classifyNodes(input: EnergyFlowInput): { } /** - * Split the household node into the consumers counted in it and the rest. - * Measurement noise can make those consumers add up to more than the + * Split the household node into the consumers and counters counted in it and + * the rest. Measurement noise can make those parts add up to more than the * residual; they are then scaled down so the household node still balances. */ -function splitHouse(house: number, consumers: FlowNode[]): FlowNode[] { - if (house <= 0 || consumers.length === 0) { +function splitHouse(house: number, houseParts: FlowNode[]): FlowNode[] { + if (house <= 0 || houseParts.length === 0) { return []; } - const consumerTotal = sumPower(consumers); - const scale = Math.min(1, house / consumerTotal); - const parts = consumers.map((node) => ({ + const partsTotal = sumPower(houseParts); + const scale = Math.min(1, house / partsTotal); + const parts = houseParts.map((node) => ({ ...node, power: node.power * scale, })); - const rest = house - consumerTotal * scale; + const rest = house - partsTotal * scale; if (rest >= MIN_EDGE_WATTS) { parts.push({ id: NODE_HOUSE_REST, @@ -196,8 +189,8 @@ export interface GroupOptions { } /** - * Collapse a list of dynamic nodes (chargePoints or consumers) into a single - * aggregated node once it exceeds the threshold. + * Collapse a list of dynamic nodes (chargePoints, consumers or counters) into + * a single aggregated node once it exceeds the threshold. */ export function groupNodes( nodes: DynamicNodeInput[], diff --git a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts index ff73ce4830..e8bd9b9ae9 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts +++ b/packages/modules/web_themes/koala/source/src/components/charts/sankeyChart/useSankeyData.ts @@ -8,14 +8,18 @@ import { allocate, groupNodes, type AllocationResult, + type DynamicNodeInput, } from './energy-allocation'; -// Aggregate chargepoints/consumers into a single node once there are more than the threshold. +// Aggregate chargepoints/consumers/counters into a single node once there are more than the threshold. const GROUP_THRESHOLD = 3; const CP_GROUP_ID = 'cp_group'; const CONSUMER_GROUP_ID = 'consumer_group'; const CONSUMER_HOUSE_GROUP_ID = 'consumer_house_group'; const CONSUMER_ID_PREFIX = 'consumer'; +const COUNTER_GROUP_ID = 'counter_group'; +const COUNTER_HOUSE_GROUP_ID = 'counter_house_group'; +const COUNTER_ID_PREFIX = 'counter'; export function useSankeyData() { const mqttStore = useMqttStore(); @@ -34,9 +38,23 @@ export function useSankeyData() { ), ); - // Consumers counted in the home consumption are drawn as parts of the - // household node, the others as sinks of their own. Group each set on its - // own so the two never collapse into one node. + // Consumers and counters counted in the home consumption are drawn as parts + // of the household node, the others as sinks of their own. Group each set on + // its own so the two never collapse into one node. + const groupInAndOut = ( + nodes: DynamicNodeInput[], + label: string, + houseGroupId: string, + groupId: string, + ) => { + const group = (inHouse: boolean, id: string) => + groupNodes( + nodes.filter((node) => (node.inHouse ?? false) === inHouse), + { threshold: GROUP_THRESHOLD, id, label }, + ).map((node) => ({ ...node, inHouse })); + return [...group(true, houseGroupId), ...group(false, groupId)]; + }; + const consumers = computed(() => { const inHouse = mqttStore.inHomeConsumption.consumerIds; const nodes = mqttStore.consumerIds.map((id) => ({ @@ -45,15 +63,31 @@ export function useSankeyData() { power: num(mqttStore.consumerPower(id, 'value')), inHouse: inHouse.includes(id), })); - const group = (inHouseSet: boolean, id: string) => - groupNodes( - nodes.filter((node) => node.inHouse === inHouseSet), - { threshold: GROUP_THRESHOLD, id, label: 'Verbraucher' }, - ).map((node) => ({ ...node, inHouse: inHouseSet })); - return [ - ...group(true, CONSUMER_HOUSE_GROUP_ID), - ...group(false, CONSUMER_GROUP_ID), - ]; + return groupInAndOut( + nodes, + 'Verbraucher', + CONSUMER_HOUSE_GROUP_ID, + CONSUMER_GROUP_ID, + ); + }); + + const counters = computed(() => { + const { inHome, notInHome } = mqttStore.shownCounterIds; + const node = (id: number, inHouse: boolean) => ({ + id: `${COUNTER_ID_PREFIX}${id}`, + label: mqttStore.componentName(id) || `Zähler ${id}`, + power: num(mqttStore.counterLocalPower('value', id)), + inHouse, + }); + return groupInAndOut( + [ + ...inHome.map((id) => node(id, true)), + ...notInHome.map((id) => node(id, false)), + ], + 'Zähler', + COUNTER_HOUSE_GROUP_ID, + COUNTER_GROUP_ID, + ); }); // Hybrid inverter/battery pairs: how much of each hybrid battery's charge is @@ -72,21 +106,6 @@ export function useSankeyData() { })), ); - // What is not counted in the home consumption and is no consumer: the - // sub-counters set to "Nein". The backend publishes only the sum, so the - // consumers, which are sinks of their own, are taken out of it. - const notInHomeOther = computed(() => { - const { consumerIds, counterIds } = mqttStore.notInHomeConsumption; - if (counterIds.length === 0) { - return 0; - } - const consumerPower = consumerIds.reduce( - (total, id) => total + num(mqttStore.consumerPower(id, 'value')), - 0, - ); - return num(mqttStore.notInHomeConsumptionPower('value')) - consumerPower; - }); - const allocation = computed(() => allocate({ grid: num(mqttStore.counterPower('value')), @@ -96,7 +115,7 @@ export function useSankeyData() { : 0, chargePoints: chargePoints.value, consumers: consumers.value, - notInHomeOther: notInHomeOther.value, + counters: counters.value, hybrid: hybrid.value, }), ); @@ -121,7 +140,8 @@ export function useSankeyData() { case 'house': case 'house_rest': return cssVar('--q-home-stroke'); - case 'not_in_home': + case COUNTER_GROUP_ID: + case COUNTER_HOUSE_GROUP_ID: return cssVar('--q-secondary-counter-stroke'); case CP_GROUP_ID: return cssVar('--q-charge-point-stroke'); @@ -139,6 +159,13 @@ export function useSankeyData() { const consumerId = Number(id.slice(CONSUMER_ID_PREFIX.length)); return mqttStore.consumerColor(consumerId) || cssVar('--q-consumer'); } + if (id.startsWith(COUNTER_ID_PREFIX)) { + const counterId = Number(id.slice(COUNTER_ID_PREFIX.length)); + return ( + mqttStore.secondaryCounterColor(counterId) || + cssVar('--q-secondary-counter-stroke') + ); + } return cssVar('--q-vehicle-stroke'); };