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..d19916fdae 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 @@ @@ -26,6 +39,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 @@ -43,11 +57,12 @@ 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. +// When chart has three columns. defineProps<{ showLegend?: boolean }>(); const $q = useQuasar(); -const { allocation, colorForNode, labelColor } = useSankeyData(); +const { allocation, compact, colorForNode, labelColor } = useSankeyData(); const hasFlows = computed(() => allocation.value.edges.length > 0); @@ -78,38 +93,55 @@ const flowLabels = createFlowLabels({ }); const chartPlugins = [hoverPlugin, flowLabels.plugin]; +// 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] + .sort((a, b) => b.power - a.power) + .forEach((node, index) => { + priority[node.id] = index; + }); + [...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; + }); + 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, sources, sinks } = allocation.value; + const { edges, nodes } = 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). - const priority: Record = {}; - [...sources] - .sort((a, b) => b.power - a.power) - .forEach((node, index) => { - priority[node.id] = index; - }); - [...sinks] - .sort((a, b) => a.power - b.power) - .forEach((node, index) => { - priority[node.id] = index; - }); - const textColor = labelColor(); return { @@ -123,7 +155,7 @@ const chartData = computed>(() => { })), labels, column: columns, - priority, + priority: nodePriority.value, colorFrom: (ctx) => flowColor( (ctx.raw as SankeyDataPoint | undefined)?.from ?? '', @@ -137,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, }, @@ -145,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, @@ -184,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; 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..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,8 +1,14 @@ -/** 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'; + +/** 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 @@ -10,7 +16,12 @@ export const NODE_HOUSE = 'house'; * 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 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; @@ -18,6 +29,10 @@ export interface EnergyFlowInput { battery: number; chargePoints: DynamicNodeInput[]; consumers: DynamicNodeInput[]; + //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 @@ -35,12 +50,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 +72,8 @@ export interface AllocationResult { nodes: FlowNode[]; sources: FlowNode[]; sinks: FlowNode[]; + /** Consumers and counters counted in the home consumption plus the remainder. */ + houseParts: FlowNode[]; totalSources: number; totalSinks: number; imbalance: number; @@ -70,55 +89,93 @@ 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 }); - } else if (consumer.power < 0) { - sources.push({ id: consumer.id, label: consumer.label, power: -consumer.power }); + for (const load of [...input.consumers, ...(input.counters ?? [])]) { + if (load.power > 0 && load.inHouse) { + houseParts.push({ + id: load.id, + label: load.label, + power: load.power, + column: COLUMN_HOUSE_PART, + }); + } else if (load.power > 0) { + sink(load.id, load.label, load.power); + } else if (load.power < 0) { + source(load.id, load.label, -load.power); } } - // Household consumption = balancing residual. + // 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); 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 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, houseParts: FlowNode[]): FlowNode[] { + if (house <= 0 || houseParts.length === 0) { + return []; + } + const partsTotal = sumPower(houseParts); + const scale = Math.min(1, house / partsTotal); + const parts = houseParts.map((node) => ({ + ...node, + power: node.power * scale, + })); + const rest = house - partsTotal * 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 { @@ -132,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[], @@ -179,7 +236,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 +292,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..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 @@ -2,21 +2,28 @@ * Store adapter for the live Sankey diagram. */ import { computed } from 'vue'; +import { useQuasar } from 'quasar'; import { useMqttStore } from 'src/stores/mqtt-store'; 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(); + const $q = useQuasar(); const num = (value: unknown): number => Number(value) || 0; @@ -31,28 +38,71 @@ 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 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) => ({ + id: `${CONSUMER_ID_PREFIX}${id}`, + label: mqttStore.consumerName(id) || `Verbraucher ${id}`, + power: num(mqttStore.consumerPower(id, 'value')), + inHouse: inHouse.includes(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 // covered by its own inverter's PV on the DC bus. pvPowerIndividual reports // 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')), + ), })), ); @@ -65,10 +115,17 @@ export function useSankeyData() { : 0, chargePoints: chargePoints.value, consumers: consumers.value, + counters: counters.value, hybrid: hybrid.value, }), ); + // 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. */ @@ -81,24 +138,37 @@ export function useSankeyData() { case 'battery': return cssVar('--q-battery-stroke'); case 'house': + case 'house_rest': return cssVar('--q-home-stroke'); + 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'); case CONSUMER_GROUP_ID: + case CONSUMER_HOUSE_GROUP_ID: return cssVar('--q-consumer'); } 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)); 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'); }; - //Node-label color for dark mode / light mode. const labelColor = (): string => { if (typeof document === 'undefined') { @@ -107,7 +177,7 @@ export function useSankeyData() { return getComputedStyle(document.body).color || '#000000'; }; - return { allocation, colorForNode, labelColor }; + return { allocation, compact, colorForNode, labelColor }; } /** 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