mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-22 11:06:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f1a35cafd | |||
| 4be7a32148 |
@@ -0,0 +1,473 @@
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { getGraphColorByIndex } from "../../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../../common/entity/context/get_entity_context";
|
||||
import type {
|
||||
Link,
|
||||
Node,
|
||||
} from "../../../../../components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../data/energy";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
// Devices below this fraction of the home total are grouped into an "Other" node
|
||||
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
|
||||
|
||||
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
|
||||
// that is not part of the chart's Node interface. The chart ignores it.
|
||||
export type SankeyDeviceNode = Node & { parent?: string };
|
||||
|
||||
interface SmallConsumer {
|
||||
id: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
const OTHER_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other";
|
||||
const UNTRACKED_LABEL_KEY =
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption";
|
||||
|
||||
/** Open more-info for a clicked sankey node that maps to an entity. */
|
||||
export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
|
||||
if (node.entityId) {
|
||||
fireEvent(el, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
/** Node small consumers and untracked flow attach to (usually "home"). */
|
||||
rootNodeId: string;
|
||||
/** Flows below this value are grouped into an "Other" node. */
|
||||
minThreshold: number;
|
||||
/** Per-parent untracked residual only shown when above this value. */
|
||||
untrackedFloor: number;
|
||||
/** Round the "Other" node value up (instantaneous cards only). */
|
||||
ceilOtherValue: boolean;
|
||||
/** Starting untracked total (the home/root value). */
|
||||
initialUntracked: number;
|
||||
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
|
||||
getValue: (id: string) => number;
|
||||
getLabel: (id: string, name: string | undefined) => string;
|
||||
getEntityId: (id: string) => string | undefined;
|
||||
findEffectiveParent: (
|
||||
includedInStat: string | undefined
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the device layer of a sankey card: one node per device, small devices
|
||||
* folded into per-parent "Other" nodes, and per-parent untracked residuals.
|
||||
* Value/label/id/entityId are supplied via callbacks so both cumulative
|
||||
* (statistic growth) and instantaneous (live power/flow) cards can share it.
|
||||
*/
|
||||
export const buildSankeyDeviceNodes = (
|
||||
options: BuildSankeyDeviceNodesOptions
|
||||
): {
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
links: Link[];
|
||||
untrackedConsumption: number;
|
||||
} => {
|
||||
const {
|
||||
devices,
|
||||
computedStyle,
|
||||
localize,
|
||||
rootNodeId,
|
||||
minThreshold,
|
||||
untrackedFloor,
|
||||
ceilOtherValue,
|
||||
initialUntracked,
|
||||
getId,
|
||||
getValue,
|
||||
getLabel,
|
||||
getEntityId,
|
||||
findEffectiveParent,
|
||||
} = options;
|
||||
|
||||
const unavailableColor = computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim();
|
||||
|
||||
const links: Link[] = [];
|
||||
const deviceNodes: SankeyDeviceNode[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
let untrackedConsumption = initialUntracked;
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = getValue(id);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
// Collect small consumers instead of folding them into untracked
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
id,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node: SankeyDeviceNode = {
|
||||
id,
|
||||
label: getLabel(id, device.name),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: getEntityId(id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Process small consumers - show a lone one directly, group clusters as "Other"
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node: SankeyDeviceNode = {
|
||||
id: consumer.id,
|
||||
label: getLabel(consumer.id, consumer.name),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: getEntityId(consumer.id),
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: SankeyDeviceNode = {
|
||||
id: otherNodeId,
|
||||
label: localize(OTHER_LABEL_KEY),
|
||||
value: ceilOtherValue ? Math.ceil(totalValue) : totalValue,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
};
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > untrackedFloor) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untracked,
|
||||
color: unavailableColor,
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { deviceNodes, parentLinks, links, untrackedConsumption };
|
||||
};
|
||||
|
||||
/** Bucket device nodes by their entity's area and floor. */
|
||||
export const groupSankeyDevicesByFloorAndArea = (
|
||||
hass: HomeAssistant,
|
||||
deviceNodes: Node[]
|
||||
): {
|
||||
areas: Record<string, { value: number; devices: Node[] }>;
|
||||
floors: Record<string, { value: number; areas: string[] }>;
|
||||
} => {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
};
|
||||
|
||||
/**
|
||||
* Organize device nodes into hierarchical sections based on parent-child
|
||||
* relationships (top-level parents first, then their children, recursively).
|
||||
*/
|
||||
export const getSankeyDeviceSections = (
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] => {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: Record<string, string> = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...getSankeyDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
};
|
||||
|
||||
export interface BuildSankeyLayoutOptions {
|
||||
hass: HomeAssistant;
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
localize: HomeAssistant["localize"];
|
||||
deviceNodes: SankeyDeviceNode[];
|
||||
parentLinks: Record<string, string>;
|
||||
rootNodeId: string;
|
||||
groupByFloor: boolean;
|
||||
groupByArea: boolean;
|
||||
untrackedConsumption: number;
|
||||
untrackedFloor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the floor/area grouping layer, emit the device section nodes with
|
||||
* their final section indices, and add the top-level untracked node.
|
||||
*/
|
||||
export const buildSankeyLayout = (
|
||||
options: BuildSankeyLayoutOptions
|
||||
): { nodes: Node[]; links: Link[] } => {
|
||||
const {
|
||||
hass,
|
||||
computedStyle,
|
||||
localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor,
|
||||
groupByArea,
|
||||
untrackedConsumption,
|
||||
untrackedFloor,
|
||||
} = options;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
const primaryColor = computedStyle.getPropertyValue("--primary-color").trim();
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
if (groupByArea || groupByFloor) {
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(
|
||||
hass,
|
||||
devicesWithoutParent
|
||||
);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(hass.floors[b]?.level ?? -Infinity) -
|
||||
(hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !groupByFloor) {
|
||||
// link "no_floor" areas to the root
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !groupByArea) {
|
||||
// If group_by_area is false, link devices to floor or root
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or root)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = getSankeyDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > untrackedFloor) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: localize(UNTRACKED_LABEL_KEY),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle.getPropertyValue("--state-unavailable-color").trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
computeConsumptionData,
|
||||
@@ -25,10 +24,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { EnergySankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<EnergySankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -138,6 +141,8 @@ class HuiEnergySankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minEnergyThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
if (types.battery) {
|
||||
const totalBatteryOut = summedData.total.from_battery ?? 0;
|
||||
const totalBatteryIn = summedData.total.to_battery ?? 0;
|
||||
@@ -262,185 +267,85 @@ class HuiEnergySankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minEnergyThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -480,113 +385,7 @@ class HuiEnergySankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -297,6 +297,15 @@ export class HuiEnergyUsageGraphCard
|
||||
to_battery: {},
|
||||
};
|
||||
|
||||
// Grid sources can be import-only or export-only; assign color indices by
|
||||
// position in the user's grid sources config so this card matches the
|
||||
// energy sources table, which uses positional indices.
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
});
|
||||
let gridIdx = 0;
|
||||
|
||||
for (const source of energyData.prefs.energy_sources) {
|
||||
if (source.type === "solar") {
|
||||
if (statIds.solar) {
|
||||
@@ -335,6 +344,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.from_grid = [gridSource.stat_energy_from];
|
||||
}
|
||||
colorIndices.from_grid[gridSource.stat_energy_from] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.from_grid[gridSource.stat_energy_from] =
|
||||
gridSource.stat_energy_to
|
||||
@@ -351,6 +361,7 @@ export class HuiEnergyUsageGraphCard
|
||||
} else {
|
||||
statIds.to_grid = [gridSource.stat_energy_to];
|
||||
}
|
||||
colorIndices.to_grid[gridSource.stat_energy_to] = gridIdx;
|
||||
if (gridSource.name) {
|
||||
statLabels.to_grid[gridSource.stat_energy_to] =
|
||||
gridSource.stat_energy_from
|
||||
@@ -361,17 +372,18 @@ export class HuiEnergyUsageGraphCard
|
||||
: gridSource.name;
|
||||
}
|
||||
}
|
||||
gridIdx++;
|
||||
}
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
|
||||
const colorIndices: Record<string, Record<string, number>> = {};
|
||||
Object.keys(colorPropertyMap).forEach((key) => {
|
||||
colorIndices[key] = {};
|
||||
if (
|
||||
key === "used_grid" ||
|
||||
key === "used_solar" ||
|
||||
key === "used_battery"
|
||||
key === "used_battery" ||
|
||||
key === "from_grid" ||
|
||||
key === "to_grid"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
|
||||
import {
|
||||
formatPowerShort,
|
||||
@@ -19,19 +18,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { PowerSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum power threshold as a fraction of total consumption to display a device node
|
||||
// Devices below this threshold will be grouped into an "Other" node
|
||||
const MIN_POWER_THRESHOLD_FACTOR = 0.001; // 0.1% of used_total
|
||||
|
||||
interface PowerData {
|
||||
solar: number;
|
||||
from_grid: number;
|
||||
@@ -48,14 +47,6 @@ interface PowerData {
|
||||
used_total: number;
|
||||
}
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-power-sankey-card")
|
||||
class HuiPowerSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -163,7 +154,8 @@ class HuiPowerSankeyCard
|
||||
const computedStyle = getComputedStyle(this);
|
||||
|
||||
// Calculate dynamic threshold based on total consumption
|
||||
const minPowerThreshold = powerData.used_total * MIN_POWER_THRESHOLD_FACTOR;
|
||||
const minPowerThreshold =
|
||||
powerData.used_total * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -286,10 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
@@ -338,252 +326,43 @@ class HuiPowerSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Collect small consumers by their effective parent
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption.forEach((device, idx) => {
|
||||
if (!device.stat_rate) {
|
||||
return;
|
||||
}
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
|
||||
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minPowerThreshold) {
|
||||
// Collect small consumers instead of skipping them
|
||||
const parentKey = effectiveParent ?? "home";
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minPowerThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers - create "Other" nodes or show single entities
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumers.length === 1) {
|
||||
// Single entity - show it directly instead of grouping
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
// Multiple entities - create "Other" group
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== "home") {
|
||||
// Has a parent device
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({
|
||||
source: parentKey,
|
||||
target: otherNodeId,
|
||||
});
|
||||
} else {
|
||||
// Top-level "Other" - will be linked to home/floor/area later
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full power
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1W
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// untracked consumption (only show if larger than 1W)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -623,10 +402,7 @@ class HuiPowerSankeyCard
|
||||
formatPowerShort(this.hass, value);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,109 +534,6 @@ class HuiPowerSankeyCard
|
||||
};
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param entityId - The entity ID to get power value from
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
formatFlowRateShort,
|
||||
@@ -18,27 +17,19 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterFlowSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
group_by_area: true,
|
||||
};
|
||||
|
||||
// Minimum flow threshold as a fraction of total inflow to display a device node.
|
||||
// Devices below this threshold will be grouped into an "Other" node.
|
||||
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
|
||||
|
||||
interface SmallConsumer {
|
||||
statRate: string;
|
||||
name: string | undefined;
|
||||
value: number;
|
||||
effectiveParent: string | undefined;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("hui-water-flow-sankey-card")
|
||||
class HuiWaterFlowSankeyCard
|
||||
extends SubscribeMixin(MobileAwareMixin(LitElement))
|
||||
@@ -177,7 +168,7 @@ class HuiWaterFlowSankeyCard
|
||||
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
|
||||
|
||||
// Calculate dynamic threshold
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
|
||||
const minFlowThreshold = effectiveTotalInflow * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const links: Link[] = [];
|
||||
@@ -292,230 +283,43 @@ class HuiWaterFlowSankeyCard
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let untrackedConsumption = effectiveTotalInflow;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
|
||||
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
if (!device.stat_rate) return;
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minFlowThreshold) {
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
}
|
||||
smallConsumersByParent.get(parentKey)!.push({
|
||||
statRate: device.stat_rate,
|
||||
name: device.name,
|
||||
value,
|
||||
effectiveParent,
|
||||
idx,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: device.stat_rate,
|
||||
label: device.name || this._getEntityLabel(device.stat_rate),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: effectiveParent,
|
||||
entityId: device.stat_rate,
|
||||
};
|
||||
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
getId: (device) => device.stat_rate,
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
|
||||
// Process small consumers
|
||||
smallConsumersByParent.forEach((consumers, parentKey) => {
|
||||
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
|
||||
if (totalValue <= 0) return;
|
||||
|
||||
if (consumers.length === 1) {
|
||||
const consumer = consumers[0];
|
||||
const node = {
|
||||
id: consumer.statRate,
|
||||
label: consumer.name || this._getEntityLabel(consumer.statRate),
|
||||
value: consumer.value,
|
||||
color: getGraphColorByIndex(consumer.idx, computedStyle),
|
||||
index: 4,
|
||||
parent: consumer.effectiveParent,
|
||||
entityId: consumer.statRate,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({ source: node.parent, target: node.id });
|
||||
} else {
|
||||
untrackedConsumption -= consumer.value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
} else {
|
||||
const otherNodeId = `other_${parentKey}`;
|
||||
const otherNode: Node = {
|
||||
id: otherNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
|
||||
),
|
||||
value: Math.ceil(totalValue),
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
};
|
||||
|
||||
if (parentKey !== rootNodeId) {
|
||||
parentLinks[otherNodeId] = parentKey;
|
||||
links.push({ source: parentKey, target: otherNodeId });
|
||||
} else {
|
||||
untrackedConsumption -= totalValue;
|
||||
}
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full flow
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1 L/min
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor, layout, title } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
floorNodeId = rootNodeId;
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({ source: rootNodeId, target: floorNodeId });
|
||||
}
|
||||
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]?.name || areaId,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: primaryColor,
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const sankeyLayout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId,
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
|
||||
// Untracked consumption (only show if > 1 L/min threshold)
|
||||
if (untrackedConsumption > 1) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: rootNodeId,
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...sankeyLayout.nodes);
|
||||
links.push(...sankeyLayout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -557,10 +361,7 @@ class HuiWaterFlowSankeyCard
|
||||
);
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
private _getCurrentFlowRate(entityId: string): number {
|
||||
@@ -574,98 +375,6 @@ class HuiWaterFlowSankeyCard
|
||||
return stateObj.attributes.friendly_name || entityId;
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: { value: 0, devices: [] },
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: { value: 0, areas: ["no_area"] },
|
||||
};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
return [deviceNodes];
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
height: 400px;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
getEnergyDataCollection,
|
||||
@@ -22,10 +21,14 @@ import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { WaterSankeyCardConfig } from "../types";
|
||||
import "../../../../components/chart/ha-sankey-chart";
|
||||
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
|
||||
const DEFAULT_CONFIG: Partial<WaterSankeyCardConfig> = {
|
||||
group_by_floor: true,
|
||||
@@ -165,6 +168,8 @@ class HuiWaterSankeyCard
|
||||
};
|
||||
nodes.push(homeNode);
|
||||
|
||||
const minWaterThreshold = homeNode.value * MIN_SANKEY_THRESHOLD_FACTOR;
|
||||
|
||||
// Add water source nodes
|
||||
const waterColor = computedStyle
|
||||
.getPropertyValue("--energy-water-color")
|
||||
@@ -180,7 +185,7 @@ class HuiWaterSankeyCard
|
||||
) || 0
|
||||
: 0;
|
||||
|
||||
if (value < 0.01) {
|
||||
if (value <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,185 +210,85 @@ class HuiWaterSankeyCard
|
||||
});
|
||||
});
|
||||
|
||||
let untrackedConsumption = homeNode.value;
|
||||
const deviceNodes: Node[] = [];
|
||||
const parentLinks: Record<string, string> = {};
|
||||
prefs.device_consumption_water.forEach((device, idx) => {
|
||||
const value =
|
||||
device.stat_consumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(
|
||||
this._data!.stats[device.stat_consumption]
|
||||
) || 0
|
||||
: 0;
|
||||
if (value < 0.01) {
|
||||
return;
|
||||
const deviceValue = (statConsumption: string) =>
|
||||
statConsumption in this._data!.stats
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
const node = {
|
||||
id: device.stat_consumption,
|
||||
label:
|
||||
device.name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
device.stat_consumption,
|
||||
this._data!.statsMetadata[device.stat_consumption]
|
||||
),
|
||||
value,
|
||||
color: getGraphColorByIndex(idx, computedStyle),
|
||||
index: 4,
|
||||
parent: device.included_in_stat,
|
||||
entityId: isExternalStatistic(device.stat_consumption)
|
||||
? undefined
|
||||
: device.stat_consumption,
|
||||
};
|
||||
if (node.parent) {
|
||||
parentLinks[node.id] = node.parent;
|
||||
links.push({
|
||||
source: node.parent,
|
||||
target: node.id,
|
||||
});
|
||||
} else {
|
||||
untrackedConsumption -= value;
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
this.hass,
|
||||
statConsumption,
|
||||
this._data!.statsMetadata[statConsumption]
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
links: deviceLinks,
|
||||
untrackedConsumption,
|
||||
} = buildSankeyDeviceNodes({
|
||||
devices: prefs.device_consumption_water,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
const { group_by_area, group_by_floor } = this._config;
|
||||
if (group_by_area || group_by_floor) {
|
||||
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
|
||||
|
||||
Object.keys(floors)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(this.hass.floors[b]?.level ?? -Infinity) -
|
||||
(this.hass.floors[a]?.level ?? -Infinity)
|
||||
)
|
||||
.forEach((floorId) => {
|
||||
let floorNodeId = `floor_${floorId}`;
|
||||
if (floorId === "no_floor" || !group_by_floor) {
|
||||
// link "no_floor" areas to home
|
||||
floorNodeId = "home";
|
||||
} else {
|
||||
nodes.push({
|
||||
id: floorNodeId,
|
||||
label: this.hass.floors[floorId].name,
|
||||
value: floors[floorId].value,
|
||||
index: 2,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: floorNodeId,
|
||||
});
|
||||
}
|
||||
floors[floorId].areas.forEach((areaId) => {
|
||||
let targetNodeId: string;
|
||||
|
||||
if (areaId === "no_area" || !group_by_area) {
|
||||
// If group_by_area is false, link devices to floor or home
|
||||
targetNodeId = floorNodeId;
|
||||
} else {
|
||||
// Create area node and link it to floor
|
||||
const areaNodeId = `area_${areaId}`;
|
||||
nodes.push({
|
||||
id: areaNodeId,
|
||||
label: this.hass.areas[areaId]!.name,
|
||||
value: areas[areaId].value,
|
||||
index: 3,
|
||||
color: computedStyle.getPropertyValue("--primary-color").trim(),
|
||||
});
|
||||
links.push({
|
||||
source: floorNodeId,
|
||||
target: areaNodeId,
|
||||
value: areas[areaId].value,
|
||||
});
|
||||
targetNodeId = areaNodeId;
|
||||
}
|
||||
|
||||
// Link devices to the appropriate target (area, floor, or home)
|
||||
areas[areaId].devices.forEach((device) => {
|
||||
links.push({
|
||||
source: targetNodeId,
|
||||
target: device.id,
|
||||
value: device.value,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
devicesWithoutParent.forEach((deviceNode) => {
|
||||
links.push({
|
||||
source: "home",
|
||||
target: deviceNode.id,
|
||||
value: deviceNode.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
|
||||
deviceSections.forEach((section, index) => {
|
||||
section.forEach((node: Node) => {
|
||||
nodes.push({ ...node, index: 4 + index });
|
||||
});
|
||||
const layout = buildSankeyLayout({
|
||||
hass: this.hass,
|
||||
computedStyle,
|
||||
localize: this.hass.localize,
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: !!group_by_floor,
|
||||
groupByArea: !!group_by_area,
|
||||
untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
// untracked consumption
|
||||
if (untrackedConsumption > 0) {
|
||||
nodes.push({
|
||||
id: "untracked",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untrackedConsumption,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 3 + deviceSections.length,
|
||||
});
|
||||
links.push({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: untrackedConsumption,
|
||||
});
|
||||
}
|
||||
nodes.push(...layout.nodes);
|
||||
links.push(...layout.links);
|
||||
|
||||
const hasData = nodes.some((node) => node.value > 0);
|
||||
|
||||
@@ -423,113 +328,7 @@ class HuiWaterSankeyCard
|
||||
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`;
|
||||
|
||||
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
|
||||
const { node } = ev.detail;
|
||||
if (node.entityId) {
|
||||
fireEvent(this, "hass-more-info", { entityId: node.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
protected _groupByFloorAndArea(deviceNodes: Node[]) {
|
||||
const areas: Record<string, { value: number; devices: Node[] }> = {
|
||||
no_area: {
|
||||
value: 0,
|
||||
devices: [],
|
||||
},
|
||||
};
|
||||
const floors: Record<string, { value: number; areas: string[] }> = {
|
||||
no_floor: {
|
||||
value: 0,
|
||||
areas: ["no_area"],
|
||||
},
|
||||
};
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const entity = this.hass.states[deviceNode.id];
|
||||
const { area, floor } = entity
|
||||
? getEntityContext(
|
||||
entity,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: null, floor: null };
|
||||
if (area) {
|
||||
if (area.area_id in areas) {
|
||||
areas[area.area_id].value += deviceNode.value;
|
||||
areas[area.area_id].devices.push(deviceNode);
|
||||
} else {
|
||||
areas[area.area_id] = {
|
||||
value: deviceNode.value,
|
||||
devices: [deviceNode],
|
||||
};
|
||||
}
|
||||
// see if the area has a floor
|
||||
if (floor) {
|
||||
if (floor.floor_id in floors) {
|
||||
floors[floor.floor_id].value += deviceNode.value;
|
||||
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
|
||||
floors[floor.floor_id].areas.push(area.area_id);
|
||||
}
|
||||
} else {
|
||||
floors[floor.floor_id] = {
|
||||
value: deviceNode.value,
|
||||
areas: [area.area_id],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
floors.no_floor.value += deviceNode.value;
|
||||
if (!floors.no_floor.areas.includes(area.area_id)) {
|
||||
floors.no_floor.areas.unshift(area.area_id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
areas.no_area.value += deviceNode.value;
|
||||
areas.no_area.devices.push(deviceNode);
|
||||
}
|
||||
});
|
||||
return { areas, floors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes device nodes into hierarchical sections based on parent-child relationships.
|
||||
*/
|
||||
protected _getDeviceSections(
|
||||
parentLinks: Record<string, string>,
|
||||
deviceNodes: Node[]
|
||||
): Node[][] {
|
||||
const parentSection: Node[] = [];
|
||||
const childSection: Node[] = [];
|
||||
const parentIds = Object.values(parentLinks);
|
||||
const remainingLinks: typeof parentLinks = {};
|
||||
|
||||
deviceNodes.forEach((deviceNode) => {
|
||||
const isChild = deviceNode.id in parentLinks;
|
||||
const isParent = parentIds.includes(deviceNode.id);
|
||||
if (isParent && !isChild) {
|
||||
// Top-level parents (have children but no parents themselves)
|
||||
parentSection.push(deviceNode);
|
||||
} else {
|
||||
childSection.push(deviceNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out links where parent is already in current parent section
|
||||
Object.entries(parentLinks).forEach(([child, parent]) => {
|
||||
if (!parentSection.some((node) => node.id === parent)) {
|
||||
remainingLinks[child] = parent;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentSection.length > 0) {
|
||||
// Recursively process child section with remaining links
|
||||
return [
|
||||
parentSection,
|
||||
...this._getDeviceSections(remainingLinks, childSection),
|
||||
];
|
||||
}
|
||||
|
||||
// Base case: no more parent-child relationships to process
|
||||
return [deviceNodes];
|
||||
fireSankeyNodeMoreInfo(this, ev.detail.node);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
SankeyDeviceNode,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
getSankeyDeviceSections,
|
||||
groupSankeyDevicesByFloorAndArea,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
import type { Node } from "../../../../../../src/components/chart/ha-sankey-chart";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../../../src/data/energy";
|
||||
import type { HomeAssistant } from "../../../../../../src/types";
|
||||
import { createMockComputedStyle } from "../../../../../fixtures/computed-style";
|
||||
import {
|
||||
createMockEntityState,
|
||||
createMockHass,
|
||||
mockLocalize,
|
||||
} from "../../../../../fixtures/hass";
|
||||
|
||||
const computedStyle = createMockComputedStyle();
|
||||
|
||||
const devices = (
|
||||
...items: DeviceConsumptionEnergyPreference[]
|
||||
): DeviceConsumptionEnergyPreference[] => items;
|
||||
|
||||
// Cumulative-style option factory (id === stat_consumption, always clickable).
|
||||
const cumulativeOpts = (
|
||||
overrides: { values: Record<string, number> } & Pick<
|
||||
BuildSankeyDeviceNodesOptions,
|
||||
"devices"
|
||||
> &
|
||||
Partial<BuildSankeyDeviceNodesOptions>
|
||||
): BuildSankeyDeviceNodesOptions => {
|
||||
const { values, ...rest } = overrides;
|
||||
return {
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: 0.01,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: 0,
|
||||
getId: (device) => device.stat_consumption,
|
||||
getValue: (id) => values[id] ?? 0,
|
||||
getLabel: (id, name) => name || id,
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent: () => undefined,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
describe("getSankeyDeviceSections", () => {
|
||||
it("returns a single section when there are no parent links", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "a", value: 1, index: 4 },
|
||||
{ id: "b", value: 2, index: 4 },
|
||||
];
|
||||
expect(getSankeyDeviceSections({}, nodes)).toEqual([nodes]);
|
||||
});
|
||||
|
||||
it("splits parents and children into ordered sections", () => {
|
||||
const parent: Node = { id: "parent", value: 10, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections({ child: "parent" }, [
|
||||
parent,
|
||||
child,
|
||||
]);
|
||||
expect(sections).toEqual([[parent], [child]]);
|
||||
});
|
||||
|
||||
it("recurses through multiple parent levels", () => {
|
||||
const grandparent: Node = { id: "grandparent", value: 10, index: 4 };
|
||||
const parent: Node = { id: "parent", value: 6, index: 4 };
|
||||
const child: Node = { id: "child", value: 4, index: 4 };
|
||||
const sections = getSankeyDeviceSections(
|
||||
{ parent: "grandparent", child: "parent" },
|
||||
[grandparent, parent, child]
|
||||
);
|
||||
expect(sections).toEqual([[grandparent], [parent], [child]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyDeviceNodes", () => {
|
||||
it("renders top-level devices and subtracts them from untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices({ stat_consumption: "a" }, { stat_consumption: "b" }),
|
||||
values: { a: 10, b: 5 },
|
||||
initialUntracked: 15,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["a", "b"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
// top-level devices carry no link here (linked in the layout step)
|
||||
expect(result.links).toEqual([]);
|
||||
expect(result.parentLinks).toEqual({});
|
||||
});
|
||||
|
||||
it("shows a lone sub-threshold device directly (no Other node)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "small" }
|
||||
),
|
||||
values: { a: 10, small: 0.005 },
|
||||
initialUntracked: 10.005,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id).sort()).toEqual(["a", "small"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("groups multiple sub-threshold devices into an Other node", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 0.007,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes).toHaveLength(1);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.id).toBe("other_home");
|
||||
expect(other.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("ceils the Other node value only when ceilOtherValue is set, but always subtracts the raw total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "s1" },
|
||||
{ stat_consumption: "s2" }
|
||||
),
|
||||
values: { s1: 0.3, s2: 0.4 },
|
||||
minThreshold: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: 0.7,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes[0];
|
||||
expect(other.value).toBe(1); // Math.ceil(0.7)
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10); // raw 0.7 subtracted
|
||||
});
|
||||
|
||||
it("skips devices whose id resolves to undefined (instantaneous with no stat_rate)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", stat_rate: undefined },
|
||||
{ stat_consumption: "b", stat_rate: "sensor.b_rate" }
|
||||
),
|
||||
values: { "sensor.b_rate": 10 },
|
||||
initialUntracked: 10,
|
||||
getId: (device) => device.stat_rate,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual(["sensor.b_rate"]);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("adds a per-parent untracked residual above the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 4 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks.child).toBe("parent");
|
||||
const residual = result.deviceNodes.find((n) =>
|
||||
n.id.startsWith("untracked_")
|
||||
);
|
||||
expect(residual?.id).toBe("untracked_parent");
|
||||
expect(residual?.value).toBe(6); // 10 - 4
|
||||
expect(result.untrackedConsumption).toBe(0); // parent (top-level) subtracted
|
||||
});
|
||||
|
||||
it("suppresses a per-parent residual at or below the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "child", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, child: 9.5 },
|
||||
untrackedFloor: 1,
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("groups a small-device cluster under its rendered parent, not into untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "parent" },
|
||||
{ stat_consumption: "s1", included_in_stat: "parent" },
|
||||
{ stat_consumption: "s2", included_in_stat: "parent" }
|
||||
),
|
||||
values: { parent: 10, s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_parent");
|
||||
expect(other?.value).toBeCloseTo(0.007, 10);
|
||||
expect(result.parentLinks.other_parent).toBe("parent");
|
||||
expect(result.links).toContainEqual({
|
||||
source: "parent",
|
||||
target: "other_parent",
|
||||
});
|
||||
// The cluster attaches to its parent, so home-level untracked only loses
|
||||
// the top-level parent (10), never the cluster total.
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
const hass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "1"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
it("buckets devices by their entity's area and floor, unknown ones under no_area", () => {
|
||||
const nodes: Node[] = [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
];
|
||||
const { areas, floors } = groupSankeyDevicesByFloorAndArea(hass, nodes);
|
||||
expect(areas.kitchen.value).toBe(3);
|
||||
expect(areas.kitchen.devices.map((n) => n.id)).toEqual(["sensor.a"]);
|
||||
expect(floors.ground.value).toBe(3);
|
||||
expect(floors.ground.areas).toContain("kitchen");
|
||||
// sensor.b has no registry entry -> no_area
|
||||
expect(areas.no_area.devices.map((n) => n.id)).toEqual(["sensor.b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyLayout", () => {
|
||||
const hass = createMockHass();
|
||||
|
||||
it("links top-level devices to the root and emits the untracked node above the floor", () => {
|
||||
const deviceNodes: SankeyDeviceNode[] = [{ id: "a", value: 10, index: 4 }];
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes,
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
expect(links).toContainEqual({ source: "home", target: "a", value: 10 });
|
||||
const untracked = nodes.find((n) => n.id === "untracked");
|
||||
expect(untracked?.value).toBe(2);
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "untracked",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses the untracked node at or below the floor", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 10, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0.5,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(nodes.some((n) => n.id === "untracked")).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a non-home root node id (single-source water-flow case)", () => {
|
||||
const { links } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [{ id: "a", value: 4, index: 4 }],
|
||||
parentLinks: {},
|
||||
rootNodeId: "sensor.source",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 1,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "sensor.source",
|
||||
target: "a",
|
||||
value: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("numbers device sections and the untracked node by section depth", () => {
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "parent", value: 10, index: 4 },
|
||||
{ id: "child", value: 4, index: 4 },
|
||||
],
|
||||
parentLinks: { child: "parent" },
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: 2,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
// section 0 -> index 4, section 1 -> index 5
|
||||
expect(nodes.find((n) => n.id === "parent")?.index).toBe(4);
|
||||
expect(nodes.find((n) => n.id === "child")?.index).toBe(5);
|
||||
// untracked sits at 3 + deviceSections.length (2)
|
||||
expect(nodes.find((n) => n.id === "untracked")?.index).toBe(5);
|
||||
});
|
||||
|
||||
it("builds floor and area nodes and links devices through them", () => {
|
||||
const groupedHass = {
|
||||
...createMockHass({
|
||||
"sensor.a": createMockEntityState("sensor.a", "3"),
|
||||
"sensor.b": createMockEntityState("sensor.b", "2"),
|
||||
}),
|
||||
entities: {
|
||||
"sensor.a": { entity_id: "sensor.a", area_id: "kitchen" },
|
||||
},
|
||||
areas: {
|
||||
kitchen: { area_id: "kitchen", name: "Kitchen", floor_id: "ground" },
|
||||
},
|
||||
floors: {
|
||||
ground: { floor_id: "ground", name: "Ground", level: 0 },
|
||||
},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const { nodes, links } = buildSankeyLayout({
|
||||
hass: groupedHass,
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: [
|
||||
{ id: "sensor.a", value: 3, index: 4 },
|
||||
{ id: "sensor.b", value: 2, index: 4 },
|
||||
],
|
||||
parentLinks: {},
|
||||
rootNodeId: "home",
|
||||
groupByFloor: true,
|
||||
groupByArea: true,
|
||||
untrackedConsumption: 0,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
|
||||
const floor = nodes.find((n) => n.id === "floor_ground");
|
||||
expect(floor?.index).toBe(2);
|
||||
expect(floor?.value).toBe(3);
|
||||
const area = nodes.find((n) => n.id === "area_kitchen");
|
||||
expect(area?.index).toBe(3);
|
||||
expect(area?.value).toBe(3);
|
||||
// root -> floor -> area -> device
|
||||
expect(links).toContainEqual({ source: "home", target: "floor_ground" });
|
||||
expect(links).toContainEqual({
|
||||
source: "floor_ground",
|
||||
target: "area_kitchen",
|
||||
value: 3,
|
||||
});
|
||||
expect(links).toContainEqual({
|
||||
source: "area_kitchen",
|
||||
target: "sensor.a",
|
||||
value: 3,
|
||||
});
|
||||
// sensor.b has no registry entry -> no_area -> linked straight to root
|
||||
expect(links).toContainEqual({
|
||||
source: "home",
|
||||
target: "sensor.b",
|
||||
value: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user