Compare commits

...

6 Commits

Author SHA1 Message Date
Petar Petrov f637626bf3 Point agent instructions at the chart optimization playbook 2026-06-11 14:59:00 +03:00
Petar Petrov 16aeb60301 Add chart data optimization playbook 2026-06-11 13:19:00 +03:00
Petar Petrov 92cee34d6c Add benchmark suite for chart data processing 2026-06-11 13:17:36 +03:00
Petar Petrov 90e62ab468 Extract state history line chart data processing into a pure function 2026-06-11 12:26:54 +03:00
Petar Petrov b75e2e3ed0 Extract statistics chart data processing into a pure function 2026-06-11 12:26:47 +03:00
Petar Petrov 4add4509af Add deterministic fixtures and characterization tests for chart data processing 2026-06-11 12:18:54 +03:00
36 changed files with 13379 additions and 839 deletions
+1
View File
@@ -289,6 +289,7 @@ For browser support, API details, and current specifications, refer to these aut
- **Test with Vitest**: Use the established test framework
- **Mock appropriately**: Mock WebSocket connections and API calls
- **Test accessibility**: Ensure components are accessible
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
## Component Library
+1
View File
@@ -58,3 +58,4 @@ test/coverage/
.claude
.cursor
.opencode
test/benchmarks/results/
+1
View File
@@ -21,6 +21,7 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -0,0 +1,480 @@
import type { LineSeriesOption } from "echarts/charts";
import type { VisualMapComponentOption } from "echarts/components";
import { getGraphColorByIndex } from "../../common/color/colors";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
};
export const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
export interface StateHistoryChartLineDataParams {
hass: HomeAssistant;
data: LineChartEntity[];
endTime: Date;
names?: Record<string, string>;
colors?: Record<string, string | undefined>;
showNames: boolean;
computedStyles: CSSStyleDeclaration;
now: Date;
}
export interface StateHistoryChartLineData {
datasets: LineSeriesOption[];
entityIds: string[];
datasetToDataIndex: number[];
visualMap?: VisualMapComponentOption[];
yAxisFractionDigits: number;
}
/**
* Transforms processed history (`LineChartEntity[]`) into ECharts series for
* `state-history-chart-line`. Pure data processing: all environment inputs
* (current time, theme style, hass) are injected so the transform is
* deterministic and benchmarkable.
*/
export function generateStateHistoryChartLineData(
params: StateHistoryChartLineDataParams
): StateHistoryChartLineData | undefined {
const { hass, computedStyles, endTime } = params;
let colorIndex = 0;
const entityStates = params.data;
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
return undefined;
}
const names = params.names || {};
const colors = params.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
clr?: string,
fill = false
) => {
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
data: [],
type: "line",
cursor: "default",
name: nameY,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: clr + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
};
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(entityState) => entityState.attributes?.hvac_action
);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(entityState) =>
entityState.attributes &&
entityState.attributes.target_temp_high !==
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
params.showNames
? hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
for (const { action, cssVar } of activeModes) {
addDataSet(
`${states.entity_id}-${action}`,
params.showNames
? hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: hass.localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue(cssVar),
true
);
}
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.high"),
})
: hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.low"),
})
: hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
params.showNames
? hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})
: hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const curTemp = safeParseFloat(
entityState.attributes.current_temperature
);
const series = [curTemp];
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
entityState.attributes.target_temp_high
);
const targetLow = safeParseFloat(
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
const hasAction = states.states.some(
(entityState) => entityState.attributes?.action
);
const hasCurrent = states.states.some(
(entityState) => entityState.attributes?.current_humidity
);
const hasHumidifying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "humidifying"
);
const hasDrying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "drying"
);
addDataSet(
states.entity_id + "-target_humidity",
params.showNames
? hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
params.showNames
? hass.localize("ui.card.humidifier.current_humidity_entity", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
}
// If action attribute is available, we used it to shade the area below the humidity.
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
params.showNames
? hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
params.showNames
? hass.localize("ui.card.humidifier.drying", {
name: name,
})
: hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
params.showNames
? hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: hass.localize("component.humidifier.entity_component._.state.on"),
undefined,
true
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const target = safeParseFloat(entityState.attributes.humidity);
// If the current humidity is not available, then we fill up to the target humidity
const current = hasCurrent
? safeParseFloat(entityState.attributes?.current_humidity)
: target;
const series = [target];
if (hasCurrent) {
series.push(current);
}
if (hasHumidifying) {
series.push(
entityState.attributes?.action === "humidifying" ? current : null
);
} else if (hasDrying) {
series.push(
entityState.attributes?.action === "drying" ? current : null
);
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) / (dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== undefined
) {
lastNullDate = date;
}
};
if (states.statistics) {
const stopTime =
!states.states || states.states.length === 0
? 0
: states.states[0].last_changed;
for (const statistic of states.statistics) {
if (stopTime && statistic.last_changed >= stopTime) {
break;
}
processData(statistic);
}
}
states.states.forEach((entityState) => {
processData(entityState);
});
if (lastNullDate !== null) {
pushData(lastNullDate, [null]);
}
}
// Add an entry for final values
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const now = params.now;
// allow 1s of leeway for "now"
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
});
const visualMap: VisualMapComponentOption[] = [];
datasets.forEach((_, seriesIndex) => {
const dataIndex = datasetToDataIndex[seriesIndex];
const data = entityStates[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS = data.states[0]?.last_changed ?? endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
return {
datasets,
entityIds,
datasetToDataIndex,
visualMap: visualMap.length > 0 ? visualMap : undefined,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
+24 -448
View File
@@ -5,15 +5,17 @@ import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { LineChartEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import {
CLIMATE_MODE_CONFIGS,
generateStateHistoryChartLineData,
} from "./state-history-chart-line-data";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -23,22 +25,9 @@ import {
import { measureTextWidth } from "../../util/text";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
// Used to recover the underlying entity_id from a legend dataset id.
// Kept in sync with the suffixes appended at dataset construction below
// for climate / water_heater / humidifier multi-attribute charts.
@@ -420,445 +409,32 @@ export class StateHistoryChartLine extends LitElement {
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const entityStates = this.data;
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
if (this.data.length === 0) {
return;
}
this._chartTime = new Date();
const endTime = this.endTime;
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
if (timestamp > endTime) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
data.forEach((d, i) => {
if (datavalues[i] === null && prevValues && prevValues[i] !== null) {
// null data values show up as gaps in the chart.
// If the current value for the dataset is null and the previous
// value of the data set is not null, then add an 'end' point
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
const addDataSet = (
id: string,
nameY: string,
clr?: string,
fill = false
) => {
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
id,
data: [],
type: "line",
cursor: "default",
name: nameY,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: clr + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
};
if (
domain === "thermostat" ||
domain === "climate" ||
domain === "water_heater"
) {
const hasHvacAction = states.states.some(
(entityState) => entityState.attributes?.hvac_action
);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
// Using step chart by step-before so manually interpolation not needed.
const hasTargetRange = states.states.some(
(entityState) =>
entityState.attributes &&
entityState.attributes.target_temp_high !==
entityState.attributes.target_temp_low
);
addDataSet(
states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
for (const { action, cssVar } of activeModes) {
addDataSet(
`${states.entity_id}-${action}`,
this.showNames
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: this.hass.localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue(cssVar),
true
);
}
if (hasTargetRange) {
addDataSet(
states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
} else {
addDataSet(
states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const curTemp = safeParseFloat(
entityState.attributes.current_temperature
);
const series = [curTemp];
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
entityState.attributes.target_temp_high
);
const targetLow = safeParseFloat(
entityState.attributes.target_temp_low
);
series.push(targetHigh, targetLow);
pushData(new Date(entityState.last_changed), series);
} else {
const target = safeParseFloat(entityState.attributes.temperature);
series.push(target);
pushData(new Date(entityState.last_changed), series);
}
});
} else if (domain === "humidifier") {
const hasAction = states.states.some(
(entityState) => entityState.attributes?.action
);
const hasCurrent = states.states.some(
(entityState) => entityState.attributes?.current_humidity
);
const hasHumidifying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "humidifying"
);
const hasDrying =
hasAction &&
states.states.some(
(entityState: LineChartState) =>
entityState.attributes?.action === "drying"
);
addDataSet(
states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
if (hasCurrent) {
addDataSet(
states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
}
// If action attribute is available, we used it to shade the area below the humidity.
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
undefined,
true
);
}
states.states.forEach((entityState) => {
if (!entityState.attributes) return;
const target = safeParseFloat(entityState.attributes.humidity);
// If the current humidity is not available, then we fill up to the target humidity
const current = hasCurrent
? safeParseFloat(entityState.attributes?.current_humidity)
: target;
const series = [target];
if (hasCurrent) {
series.push(current);
}
if (hasHumidifying) {
series.push(
entityState.attributes?.action === "humidifying" ? current : null
);
} else if (hasDrying) {
series.push(
entityState.attributes?.action === "drying" ? current : null
);
} else {
series.push(entityState.state === "on" ? current : null);
}
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;
let lastNullDate: Date | null = null;
// Process chart data.
// When state is `unknown`, calculate the value and break the line.
const processData = (entityState: LineChartState) => {
const value = safeParseFloat(entityState.state);
const date = new Date(entityState.last_changed);
if (value !== null && lastNullDate) {
const dateTime = date.getTime();
const lastNullDateTime = lastNullDate.getTime();
const lastDateTime = lastDate?.getTime();
const tmpValue =
(value - lastValue) *
((lastNullDateTime - lastDateTime) /
(dateTime - lastDateTime)) +
lastValue;
pushData(lastNullDate, [tmpValue]);
pushData(new Date(lastNullDateTime + 1), [null]);
pushData(date, [value]);
lastDate = date;
lastValue = value;
lastNullDate = null;
} else if (value !== null && lastNullDate === null) {
pushData(date, [value]);
lastDate = date;
lastValue = value;
} else if (
value === null &&
lastNullDate === null &&
lastValue !== undefined
) {
lastNullDate = date;
}
};
if (states.statistics) {
const stopTime =
!states.states || states.states.length === 0
? 0
: states.states[0].last_changed;
for (const statistic of states.statistics) {
if (stopTime && statistic.last_changed >= stopTime) {
break;
}
processData(statistic);
}
}
states.states.forEach((entityState) => {
processData(entityState);
});
if (lastNullDate !== null) {
pushData(lastNullDate, [null]);
}
}
// Add an entry for final values
pushData(endTime, prevValues);
// For sensors, append current state if viewing recent data
const now = new Date();
// allow 1s of leeway for "now"
const isUpToNow = now.getTime() - endTime.getTime() <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = this.hass.states[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
// Concat two arrays
Array.prototype.push.apply(datasets, data);
const data = generateStateHistoryChartLineData({
hass: this.hass,
data: this.data,
endTime: this.endTime,
names: this.names,
colors: this.colors,
showNames: this.showNames,
computedStyles: getComputedStyle(this),
now: new Date(),
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
const visualMap: VisualMapComponentOption[] = [];
this._chartData.forEach((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
visualMap.push({
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
});
});
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
if (!data) {
return;
}
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
this._entityIds = data.entityIds;
this._datasetToDataIndex = data.datasetToDataIndex;
this._visualMap = data.visualMap;
}
private _formatYAxisLabel = (value: number) => {
@@ -0,0 +1,436 @@
import type {
BarSeriesOption,
LineSeriesOption,
ZRColor,
} from "echarts/types/dist/shared";
import { getGraphColorByIndex } from "../../common/color/colors";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
} from "../../data/recorder";
import {
getDisplayUnit,
getStatisticLabel,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export interface StatisticsChartLegendItem {
id: string;
name: string;
color?: ZRColor;
borderColor?: ZRColor;
noLabelClick?: boolean;
}
export interface StatisticsChartDataParams {
hass: HomeAssistant;
statisticsData: Statistics;
statisticsMetaData: Record<string, StatisticsMetaData>;
names?: Record<string, string>;
colors?: Record<string, string | undefined>;
unit?: string;
endTime?: Date;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
period?: string;
hideLegend: boolean;
hiddenStats: ReadonlySet<string>;
computedStyle: CSSStyleDeclaration;
now: Date;
}
export interface StatisticsChartData {
datasets: (LineSeriesOption | BarSeriesOption)[];
legendData: StatisticsChartLegendItem[];
statisticIds: string[];
/** Chart unit, inferred from statistics metadata when not set explicitly */
unit?: string;
yAxisFractionDigits: number;
}
/**
* Transforms raw statistics into ECharts series for `statistics-chart`.
* Pure data processing: all environment inputs (current time, theme style,
* hass) are injected so the transform is deterministic and benchmarkable.
*/
export function generateStatisticsChartData(
params: StatisticsChartDataParams
): StatisticsChartData | undefined {
const { hass, statisticsMetaData, computedStyle, now, hiddenStats } = params;
let colorIndex = 0;
const chartType = params.chartType.startsWith("line") ? "line" : "bar";
const chartStacked = params.chartType.endsWith("stack");
const statisticsData = Object.entries(params.statisticsData);
const totalDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: StatisticsChartLegendItem[] = [];
const statisticIds: string[] = [];
let endTime: Date;
if (statisticsData.length === 0) {
return undefined;
}
endTime =
params.endTime ||
// Get the highest date from the last date of each statistic
new Date(
Math.max(
...statisticsData.map(([_, stats]) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > now) {
endTime = now;
}
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
let unit = params.unit;
if (!unit) {
let inferredUnit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
if (inferredUnit === undefined) {
inferredUnit = statisticUnit;
} else if (inferredUnit !== null && inferredUnit !== statisticUnit) {
// Clear unit if not all statistics have same unit
inferredUnit = null;
}
});
if (inferredUnit) {
unit = inferredUnit;
}
}
const names = params.names || {};
const colors = params.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
if (name === undefined) {
name = getStatisticLabel(hass, statistic_id, meta);
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: StatisticsChartLegendItem[] = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(params.period === "5minute" || params.period === "hour");
const pushData = (
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
}
});
prevValues = dataValues;
prevEndTime = limit;
};
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(colorIndex, computedStyle);
colorIndex++;
}
const statTypes: StatisticType[] = [];
const hasMean =
params.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
const hasMax =
params.statTypes.includes("max") && statisticsHaveType(stats, "max");
const hasMin =
params.statTypes.includes("min") && statisticsHaveType(stats, "min");
const drawBands =
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const hasState = params.statTypes.includes("state");
const bandTop = hasMax ? "max" : "mean";
const bandBottom = hasMin ? "min" : "mean";
const sortedTypes = drawBands
? [...params.statTypes].sort((a, b) => {
if (a === "min" || b === "max") {
return -1;
}
if (a === "max" || b === "min") {
return +1;
}
return 0;
})
: params.statTypes;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === bandTop || type === bandBottom);
statTypes.push(type);
const borderColor =
(band && hasMin && hasMax && hasMean) ||
(hasState && ["change", "sum"].includes(type))
? color + (params.hideLegend ? "00" : "7F")
: color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: chartType,
smooth: chartType === "line" ? 0.4 : false,
cursor: "default",
data: [],
name: name
? `${name} (${hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})`
: hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
chartType === "bar"
? {
borderColor,
borderWidth: 1.5,
}
: undefined,
color: chartType === "bar" ? backgroundColor : borderColor,
};
if (chartStacked) {
series.stack = `band-stacked`;
series.stackStrategy = "samesign";
if (chartType === "line") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} else if (band && chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
series.stackOrder = "seriesDesc";
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
}
if (!params.hideLegend) {
const showLegend = hasMean
? type === "mean"
: displayedLegend === false;
if (showLegend) {
statLegendData.push({
id: statistic_id,
name,
color: series.color as ZRColor,
borderColor: series.itemStyle?.borderColor,
noLabelClick: isExternalStatistic(statistic_id),
});
}
displayedLegend = displayedLegend || showLegend;
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
let prevDate: Date | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
} else if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
} else {
val.push(stat[type] ?? null);
}
dataValues.push(val);
});
if (!hiddenStats.has(statistic_id)) {
pushData(startDate, endDate, endTime, dataValues);
}
});
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
});
}
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(hass, statistic_id, meta);
if (
displayCurrentState &&
!chartStacked &&
(!unit || !statisticUnit || unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
const stateObj = hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
if (isFinite(currentValue) && !hiddenStats.has(statistic_id)) {
// Then push the current state at now
statTypes.forEach((type, i) => {
if (type === "sum" || type === "change") {
// Skip cumulative types - need special calculation.
return;
}
const val: (number | null)[] = [];
if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
// For band chart, current value is both min and max, so diff is 0
val.push(0);
val.push(currentValue);
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
}
}
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "bar") {
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
id: id,
name: name,
color,
itemStyle: {
borderColor,
},
type: chartType,
data: [],
xAxisIndex: 1,
});
});
return {
datasets: totalDataSets,
legendData,
statisticIds,
unit,
yAxisFractionDigits: computeYAxisFractionDigits(yMin, yMax),
};
}
+26 -391
View File
@@ -1,14 +1,12 @@
import type {
BarSeriesOption,
LineSeriesOption,
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -27,13 +25,7 @@ import type {
StatisticsMetaData,
StatisticType,
} from "../../data/recorder";
import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import { getStatisticMetadata, isExternalStatistic } from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
@@ -41,8 +33,7 @@ import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import { generateStatisticsChartData } from "./statistics-chart-data";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -503,391 +494,35 @@ export class StatisticsChart extends LitElement {
this.metadata ||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
let colorIndex = 0;
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
const chartStacked = this.chartType.endsWith("stack");
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: {
id: string;
name: string;
color?: ZRColor;
borderColor?: ZRColor;
noLabelClick?: boolean;
}[] = [];
const statisticIds: string[] = [];
let endTime: Date;
const data = generateStatisticsChartData({
hass: this.hass,
statisticsData: this.statisticsData,
statisticsMetaData,
names: this.names,
colors: this.colors,
unit: this.unit,
endTime: this.endTime,
statTypes: this.statTypes,
chartType: this.chartType,
period: this.period,
hideLegend: this.hideLegend,
hiddenStats: this._hiddenStats,
computedStyle: this._computedStyle || getComputedStyle(this),
now: new Date(),
});
if (statisticsData.length === 0) {
if (!data) {
return;
}
endTime =
this.endTime ||
// Get the highest date from the last date of each statistic
new Date(
Math.max(
...statisticsData.map(([_, stats]) =>
new Date(stats[stats.length - 1].start).getTime()
)
)
);
if (endTime > new Date()) {
endTime = new Date();
}
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const now = new Date();
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
if (!this.unit) {
let unit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (unit === undefined) {
unit = statisticUnit;
} else if (unit !== null && unit !== statisticUnit) {
// Clear unit if not all statistics have same unit
unit = null;
}
});
if (unit) {
this.unit = unit;
}
}
const names = this.names || {};
const colors = this.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
if (name === undefined) {
name = getStatisticLabel(this.hass, statistic_id, meta);
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: typeof legendData = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(this.period === "5minute" || this.period === "hour");
const pushData = (
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
}
});
prevValues = dataValues;
prevEndTime = limit;
};
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
}
const statTypes: this["statTypes"] = [];
const hasMean =
this.statTypes.includes("mean") && statisticsHaveType(stats, "mean");
const hasMax =
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
const hasMin =
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
const drawBands =
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
const hasState = this.statTypes.includes("state");
const bandTop = hasMax ? "max" : "mean";
const bandBottom = hasMin ? "min" : "mean";
const sortedTypes = drawBands
? [...this.statTypes].sort((a, b) => {
if (a === "min" || b === "max") {
return -1;
}
if (a === "max" || b === "min") {
return +1;
}
return 0;
})
: this.statTypes;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === bandTop || type === bandBottom);
statTypes.push(type);
const borderColor =
(band && hasMin && hasMax && hasMean) ||
(hasState && ["change", "sum"].includes(type))
? color + (this.hideLegend ? "00" : "7F")
: color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: chartType,
smooth: chartType === "line" ? 0.4 : false,
cursor: "default",
data: [],
name: name
? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})`
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
chartType === "bar"
? {
borderColor,
borderWidth: 1.5,
}
: undefined,
color: chartType === "bar" ? backgroundColor : borderColor,
};
if (chartStacked) {
series.stack = `band-stacked`;
series.stackStrategy = "samesign";
if (chartType === "line") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} else if (band && chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
series.stackOrder = "seriesDesc";
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
}
if (!this.hideLegend) {
const showLegend = hasMean
? type === "mean"
: displayedLegend === false;
if (showLegend) {
statLegendData.push({
id: statistic_id,
name,
color: series.color as ZRColor,
borderColor: series.itemStyle?.borderColor,
noLabelClick: isExternalStatistic(statistic_id),
});
}
displayedLegend = displayedLegend || showLegend;
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
let prevDate: Date | null = null;
// Process chart data.
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
prevDate = startDate;
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val.push(0);
firstSum = stat.sum;
} else {
val.push((stat.sum || 0) - firstSum);
}
} else if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));
val.push(top);
} else {
val.push(stat[type] ?? null);
}
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(startDate, endDate, endTime, dataValues);
}
});
// For line charts, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
});
}
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (
displayCurrentState &&
!chartStacked &&
(!this.unit || !statisticUnit || this.unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
const stateObj = this.hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
if (
isFinite(currentValue) &&
!this._hiddenStats.has(statistic_id)
) {
// Then push the current state at now
statTypes.forEach((type, i) => {
if (type === "sum" || type === "change") {
// Skip cumulative types - need special calculation.
return;
}
const val: (number | null)[] = [];
if (
type === bandTop &&
chartType === "line" &&
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
// For band chart, current value is both min and max, so diff is 0
val.push(0);
val.push(currentValue);
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
}
}
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(legendData, statLegendData);
});
if (chartType === "bar") {
fillDataGapsAndRoundCaps(
totalDataSets as BarSeriesOption[],
chartStacked
);
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
id: id,
name: name,
color,
itemStyle: {
borderColor,
},
type: chartType,
data: [],
xAxisIndex: 1,
});
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = totalDataSets;
if (legendData.length !== this._legendData?.length) {
this.unit = data.unit;
this._yAxisFractionDigits = data.yAxisFractionDigits;
this._chartData = data.datasets;
if (data.legendData.length !== this._legendData?.length) {
// only update the legend if it has changed or it will trigger options update
this._legendData =
legendData.length > 1
? legendData.map(({ id, name, noLabelClick }) => ({
data.legendData.length > 1
? data.legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
@@ -895,7 +530,7 @@ export class StatisticsChart extends LitElement {
: // if there is only one entity, let the base chart handle the legend
undefined;
}
this._statisticIds = statisticIds;
this._statisticIds = data.statisticIds;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
+166
View File
@@ -0,0 +1,166 @@
# Chart data processing benchmarks
This directory contains benchmarks and an optimization playbook for the
frontend's chart data processing: the code that sanitizes, downsamples, and
transforms history/statistics/energy data for visualization. This data can be
large (weeks of 5-minute statistics, 100k+ state histories) and refreshes
often, so this processing should put as little load as possible on client
devices.
The harness has three parts:
1. **Deterministic fixtures** (`test/fixtures/`) — seeded generators for
history states, statistics, and energy data. The same seed always produces
the same payload.
2. **Characterization tests** — snapshot tests that pin the _exact current
output_ of every transform (see the target map below). They are the
correctness contract for optimization work: a performance change must
leave all outputs bit-identical.
3. **Benchmarks** (this directory) — `vitest bench` suites over the same
fixtures, runnable with machine-readable output for baseline comparison.
## Running
```bash
yarn test:bench # run all benchmarks
yarn test:bench down-sample # run one suite
# Record a baseline, then compare after a change:
yarn test:bench --outputJson test/benchmarks/results/baseline.json
yarn test:bench --compare test/benchmarks/results/baseline.json --outputJson test/benchmarks/results/after.json
```
`test/benchmarks/results/` is gitignored. The JSON reports include `hz`,
`mean`, `rme` (relative margin of error), and percentiles per benchmark;
compare mode also prints the delta next to each result.
Benchmarks run in a plain node environment (`test/vitest.bench.config.ts`)
with sequential files for stable timings. Expect run-to-run noise of a few
percent; always record the baseline twice and check `rme` before drawing
conclusions.
## Optimization target map
| Transform | Source | Benchmark | Characterization test |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------- |
| `downSampleLineData` | `src/components/chart/down-sample.ts` | `down-sample.bench.ts` | `test/components/chart/down-sample.test.ts` |
| `computeHistory` | `src/data/history.ts` | `history.bench.ts` | `test/data/history-characterization.test.ts` |
| `HistoryStream.processMessage` | `src/data/history.ts` | `history-stream.bench.ts` | `test/data/history-characterization.test.ts` |
| `convertStatisticsToHistory`, `mergeHistoryResults` | `src/data/history.ts` | `statistics.bench.ts` | `test/data/history-characterization.test.ts` |
| `generateStatisticsChartData` | `src/components/chart/statistics-chart-data.ts` | `statistics-chart-data.bench.ts` | `test/components/chart/statistics-chart-data.test.ts` |
| `generateStateHistoryChartLineData` | `src/components/chart/state-history-chart-line-data.ts` | `state-history-chart-line-data.bench.ts` | `test/components/chart/state-history-chart-line-data.test.ts` |
| `fillDataGapsAndRoundCaps` | `src/components/chart/round-caps.ts` | `chart-helpers.bench.ts` | `test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts` |
| `computeYAxisFractionDigits` | `src/components/chart/y-axis-fraction-digits.ts` | `chart-helpers.bench.ts` | `test/components/chart/y-axis-fraction-digits.test.ts` |
| `getSummedData`, `computeConsumptionData`, `computeConsumptionSingle` | `src/data/energy.ts` | `energy.bench.ts` | `test/data/energy-characterization.test.ts` |
| `fillLineGaps`, `computeStatMidpoint`, `getCompareTransform`, `getSuggestedMax` | `src/panels/lovelace/cards/energy/common/energy-chart-options.ts` | `energy.bench.ts` | `test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts` |
Call frequency notes (what makes a target worth optimizing):
- `computeHistory` runs on **every history stream message** for every visible
history graph card; `HistoryStream.processMessage` merges and purges on the
same cadence.
- `downSampleLineData` runs in `ha-chart-base._getSeries()` on **every chart
render** of every chart on screen.
- `generateStatisticsChartData` / `generateStateHistoryChartLineData` run on
every data or config change of their charts (`MIN_TIME_BETWEEN_UPDATES` =
5 minutes keeps charts refreshing even without new data).
- `getSummedData` / `computeConsumptionData` run per energy collection update
and feed most cards on the energy dashboard. Both are `memoizeOne`-wrapped:
benchmarks must pass a fresh object reference per iteration or they only
measure the cache hit.
### Not yet extracted (refactor before optimizing)
The energy graph cards still embed their series generation in the Lit
component (`_processDataSet`, `_processTotal`, `_processStatistics`,
`_processForecast`, `_processUntracked`):
- `src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts`
- `src/panels/lovelace/cards/energy/hui-energy-solar-graph-card.ts`
- `src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts`
- `src/panels/lovelace/cards/energy/hui-energy-gas-graph-card.ts`
- `src/panels/lovelace/cards/energy/hui-energy-water-graph-card.ts`
- `src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts`
To optimize one of these, first repeat the extraction pattern used for
`statistics-chart-data.ts` / `state-history-chart-line-data.ts`:
1. Move the processing methods into a pure module next to the card, taking an
options object. Inject every environment read: `hass` (or the narrow
pieces used), `getComputedStyle(this)` results, and `new Date()` as a
`now: Date` parameter. **Zero logic changes** — this commit must be a
mechanical move reviewable side-by-side.
2. Add characterization tests for the extracted function using the
`test/fixtures/` generators (snapshot small inputs, `digestResult` large
ones) and a benchmark file here.
3. Only then optimize, following the loop below.
## The optimization loop
Work on **one target at a time**:
1. **Preflight**`yarn test` must be green before you start.
2. **Coverage check** — confirm the target has characterization coverage for
the code paths you will touch; add missing cases in a separate commit
_before_ changing the implementation.
3. **Baseline** — run `yarn test:bench --outputJson .../baseline.json`
**twice**; note the `rme` and the spread between runs. That spread is your
noise floor.
4. **Optimize** — change the implementation. Stay within the guardrails
below.
5. **Verify correctness**`yarn test` and `yarn lint` must pass. Never run
`yarn lint:types` with file arguments.
6. **Measure** — `yarn test:bench --compare .../baseline.json --outputJson
.../after.json`.
7. **Report** — include a before/after table (`mean`, `hz`, `rme`) for every
affected benchmark, generated from the two JSON files.
### Guardrails
- **Outputs must be bit-identical.** Never run vitest with `-u`/`--update`;
never edit files under `__snapshots__/` or `test/fixtures/`; never modify
existing characterization tests (adding new cases is fine, in its own
commit). Even a change in floating-point summation order is a behavior
change. If an optimization seems to require changing the output, stop and
escalate with a written justification instead.
- **No public API changes.** Exported function signatures and component
properties stay as they are.
- **No new runtime dependencies.**
- **Bundle size matters.** This code ships to browsers and CI tracks bundle
stats; keep added source small.
- **Strategy preference, in order:**
1. Algorithmic: single-pass loops, avoiding repeated full reprocessing per
message, avoiding intermediate allocations.
2. Memoization: `memoize-one` is already a dependency; only useful when
inputs are reference-stable across calls.
3. Incremental/delta processing: reuse the previous result when only new
data arrived (e.g. history stream updates).
4. Web workers: an architecture change — propose it with measurements, do
not implement it as part of a routine optimization pass.
### Acceptance criteria
An optimization is accepted only if **all** of the following hold:
- `yarn test` fully green and `yarn lint` clean.
- `git diff` contains no changes under `test/fixtures/`, `__snapshots__/`,
or existing characterization tests.
- The declared target improves by **≥ 10% mean time**, and the improvement is
larger than 3× the combined `rme` of the baseline and after runs.
- No other benchmark regresses by more than max(5%, 2× its `rme`).
- No new dependencies and no public export changes.
- The report includes the before/after table described above.
## Fixture conventions
- All fixtures are seeded (`test/fixtures/random.ts`, mulberry32); identical
seeds yield identical data. Scale tiers: `SCALES = { small: 1k, medium:
10k, large: 100k }` total states.
- Everything is anchored at `FIXED_EPOCH_MS` (2024-01-01T00:00:00Z) **except**
`HistoryStream` scenarios: the stream purges against `Date.now()`, so its
fixtures take a `startMs` derived from the current time. Characterization
tests pin the clock with `vi.useFakeTimers()`; benchmarks use the real
`Date.now()` at module load (tinybench needs a real clock — never fake
timers in a benchmark).
- Large outputs are snapshotted via `digestResult()` (`test/fixtures/digest.ts`),
a structural checksum that stays small but catches numeric drift.
+49
View File
@@ -0,0 +1,49 @@
import { bench, describe } from "vitest";
import type { BarSeriesOption } from "echarts/types/dist/shared";
import { fillDataGapsAndRoundCaps } from "../../src/components/chart/round-caps";
import { computeYAxisFractionDigits } from "../../src/components/chart/y-axis-fraction-digits";
import { FIXED_EPOCH_MS } from "../fixtures/history-states";
import { createSeededRandom } from "../fixtures/random";
const buildBarDatasets = (
seed: number,
stacked: boolean
): BarSeriesOption[] => {
const random = createSeededRandom(seed);
const datasets: BarSeriesOption[] = [];
for (let series = 0; series < 8; series++) {
const data: [number, number][] = [];
for (let i = 0; i < 744; i++) {
if (random() < 0.2) continue; // gaps to fill
data.push([
FIXED_EPOCH_MS + i * 3_600_000,
(random() - 0.3) * 5, // mix of positive and negative bars
]);
}
datasets.push({
data,
stack: stacked ? `stack-${series % 2}` : undefined,
});
}
return datasets;
};
describe("fillDataGapsAndRoundCaps", () => {
// fillDataGapsAndRoundCaps mutates its input, so rebuild per iteration;
// build cost is included in both baseline and comparison runs.
bench("stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(1, true), true);
});
bench("non-stacked, 8 series, month of hourly bars", () => {
fillDataGapsAndRoundCaps(buildBarDatasets(2, false), false);
});
});
describe("computeYAxisFractionDigits", () => {
bench("typical ranges", () => {
computeYAxisFractionDigits(0, 100);
computeYAxisFractionDigits(1.85, 2.0);
computeYAxisFractionDigits(0, 0.005);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { bench, describe } from "vitest";
import { downSampleLineData } from "../../src/components/chart/down-sample";
import { FIXED_EPOCH_MS, SCALES } from "../fixtures/history-states";
import { createSeededRandom } from "../fixtures/random";
// A typical chart is a few hundred CSS pixels wide
const MAX_DETAILS = 500;
const generatePoints = (seed: number, count: number): [number, number][] => {
const random = createSeededRandom(seed);
const points: [number, number][] = [];
let y = 100;
for (let i = 0; i < count; i++) {
y = Math.max(0, y + (random() - 0.5) * 10);
points.push([FIXED_EPOCH_MS + i * 30_000, y]);
}
return points;
};
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
downSampleLineData(small, MAX_DETAILS);
});
bench("min/max medium (10k points)", () => {
downSampleLineData(medium, MAX_DETAILS);
});
bench(
"min/max large (100k points)",
() => {
downSampleLineData(large, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"mean large (100k points)",
() => {
downSampleLineData(large, MAX_DETAILS, undefined, undefined, true);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large object points (100k points)",
() => {
downSampleLineData(largeObjects, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
+73
View File
@@ -0,0 +1,73 @@
import { bench, describe } from "vitest";
import type { LineSeriesOption } from "echarts/charts";
import {
computeConsumptionData,
computeConsumptionSingle,
getSummedData,
} from "../../src/data/energy";
import { fillLineGaps } from "../../src/panels/lovelace/cards/energy/common/energy-chart-options";
import { generateEnergyData } from "../fixtures/energy";
import { createSeededRandom } from "../fixtures/random";
import { FIXED_EPOCH_MS } from "../fixtures/history-states";
// getSummedData and computeConsumptionData are memoizeOne-wrapped: passing
// the same object reference would only measure the cache hit. Shallow-clone
// the input each iteration to measure the real computation.
const monthHourly = generateEnergyData(1, { days: 31, period: "hour" });
const monthHourlyCompare = generateEnergyData(2, {
days: 31,
period: "hour",
compare: true,
});
const summedMonth = getSummedData(
generateEnergyData(3, { days: 31, period: "hour" })
).summedData;
const buildLineDatasets = (seed: number): LineSeriesOption[] => {
const random = createSeededRandom(seed);
const datasets: LineSeriesOption[] = [];
for (let series = 0; series < 10; series++) {
const data: [number, number][] = [];
for (let i = 0; i < 744; i++) {
// create gaps so fillLineGaps has buckets to fill
if (random() < 0.3) continue;
data.push([FIXED_EPOCH_MS + i * 3_600_000, random() * 5]);
}
datasets.push({ data });
}
return datasets;
};
describe("getSummedData", () => {
bench("month of hourly data, full source setup", () => {
getSummedData({ ...monthHourly });
});
bench("month of hourly data with compare", () => {
getSummedData({ ...monthHourlyCompare });
});
});
describe("computeConsumptionData", () => {
bench("month of hourly summed data", () => {
computeConsumptionData({ ...summedMonth }, undefined);
});
});
describe("computeConsumptionSingle", () => {
bench("full battery + solar flow", () => {
computeConsumptionSingle({
from_grid: 2,
to_grid: 1,
solar: 6,
to_battery: 3,
from_battery: 2,
});
});
});
describe("fillLineGaps", () => {
bench("10 series, month of hourly buckets, 30% gaps", () => {
fillLineGaps(buildLineDatasets(4).map((d) => ({ ...d })));
});
});
+62
View File
@@ -0,0 +1,62 @@
import { bench, describe } from "vitest";
import { HistoryStream } from "../../src/data/history";
import type { HistoryStreamMessage } from "../../src/data/history";
import { generateNumericSensorStates } from "../fixtures/history-states";
import { createMockHass } from "../fixtures/hass";
// HistoryStream purges against the real clock, so these fixtures are
// anchored relative to Date.now() at module load (never use fake timers in
// benchmarks — tinybench needs the real clock).
const hass = createMockHass();
const HOURS_TO_SHOW = 24;
const nowMs = Date.now();
const windowStartMs = nowMs - HOURS_TO_SHOW * 60 * 60 * 1000;
// Initial chunk: a day of data for several entities, partially before the
// purge window so every message triggers purge work.
const buildInitialMessage = (): HistoryStreamMessage => {
const states = {};
for (let i = 0; i < 5; i++) {
states[`sensor.power_${i}`] = generateNumericSensorStates(i, {
count: 5_000,
startMs: windowStartMs - 60 * 60 * 1000,
intervalMs: (HOURS_TO_SHOW * 60 * 60 * 1000) / 5_000,
jitter: 0,
});
}
return { states };
};
const initialMessage = buildInitialMessage();
// Pre-built incremental updates appended near "now"
const incrementalMessages: HistoryStreamMessage[] = [];
for (let i = 0; i < 20; i++) {
const states = {};
for (let e = 0; e < 5; e++) {
states[`sensor.power_${e}`] = generateNumericSensorStates(
1000 + i * 5 + e,
{
count: 10,
startMs: nowMs - (20 - i) * 60 * 1000,
intervalMs: 5_000,
jitter: 0,
}
);
}
incrementalMessages.push({ states });
}
describe("HistoryStream.processMessage", () => {
bench(
"initial chunk + 20 incremental updates (5 entities, 25k states)",
() => {
const stream = new HistoryStream(hass, HOURS_TO_SHOW);
stream.processMessage(initialMessage);
for (const message of incrementalMessages) {
stream.processMessage(message);
}
},
{ time: 1000, warmupIterations: 2 }
);
});
+76
View File
@@ -0,0 +1,76 @@
import { bench, describe } from "vitest";
import { computeHistory } from "../../src/data/history";
import {
generateMixedHistory,
generateNumericSensorStates,
} from "../fixtures/history-states";
import { createMockHass, mockLocalize } from "../fixtures/hass";
import type { HistoryStates } from "../../src/data/history";
const SENSOR_NUMERIC_DEVICE_CLASSES = ["power", "energy", "temperature"];
const hass = createMockHass();
const medium = generateMixedHistory(1, "medium");
const large = generateMixedHistory(2, "large");
const singleDense: HistoryStates = {
"sensor.power_meter": generateNumericSensorStates(3, { count: 100_000 }),
};
const manyEntities: HistoryStates = {};
for (let i = 0; i < 20; i++) {
manyEntities[`sensor.power_${i}`] = generateNumericSensorStates(100 + i, {
count: 5_000,
});
}
describe("computeHistory", () => {
bench("mixed medium (10k states)", () => {
computeHistory(hass, medium, [], mockLocalize, [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
});
bench(
"mixed large (100k states)",
() => {
computeHistory(hass, large, [], mockLocalize, [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"mixed large with splitDeviceClasses (100k states)",
() => {
computeHistory(
hass,
large,
[],
mockLocalize,
[...SENSOR_NUMERIC_DEVICE_CLASSES],
true
);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"single dense sensor (100k states)",
() => {
computeHistory(hass, singleDense, [], mockLocalize, [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"many entities (20 x 5k states)",
() => {
computeHistory(hass, manyEntities, [], mockLocalize, [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
},
{ time: 1000, warmupIterations: 2 }
);
});
+8
View File
@@ -0,0 +1,8 @@
// Bench setup for the node environment. Unlike test/setup.ts this must not
// assign global.navigator — modern node exposes it as a getter-only property.
global.window = (global.window ?? {}) as any;
// src/data/external.ts reads location.search at module load
global.location = (global.location ?? { search: "" }) as any;
global.__DEMO__ = false;
global.__DEV__ = false;
@@ -0,0 +1,60 @@
import { bench, describe } from "vitest";
import { generateStateHistoryChartLineData } from "../../src/components/chart/state-history-chart-line-data";
import { computeHistory } from "../../src/data/history";
import { createMockComputedStyle } from "../fixtures/computed-style";
import { createMockHass, mockLocalize } from "../fixtures/hass";
import {
FIXED_EPOCH_MS,
generateClimateStates,
generateMixedHistory,
} from "../fixtures/history-states";
const SENSOR_NUMERIC_DEVICE_CLASSES = ["power", "energy", "temperature"];
const computedStyles = createMockComputedStyle();
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
const toLineChartEntities = (history) =>
computeHistory(
hass,
history,
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
).line.flatMap((unit) => unit.data);
const medium = toLineChartEntities(generateMixedHistory(1, "medium"));
const large = toLineChartEntities(generateMixedHistory(2, "large"));
const climate = toLineChartEntities({
"climate.thermostat": generateClimateStates(3, { count: 20_000 }),
});
const base = {
hass,
computedStyles,
showNames: true,
endTime: new Date(FIXED_EPOCH_MS + 30 * dayMs),
now: new Date(FIXED_EPOCH_MS + 30 * dayMs),
} as const;
describe("generateStateHistoryChartLineData", () => {
bench("mixed medium (10k states)", () => {
generateStateHistoryChartLineData({ ...base, data: medium });
});
bench(
"mixed large (100k states)",
() => {
generateStateHistoryChartLineData({ ...base, data: large });
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"climate entity (20k states)",
() => {
generateStateHistoryChartLineData({ ...base, data: climate });
},
{ time: 1000, warmupIterations: 2 }
);
});
@@ -0,0 +1,109 @@
import { bench, describe } from "vitest";
import { generateStatisticsChartData } from "../../src/components/chart/statistics-chart-data";
import { StatisticMeanType } from "../../src/data/recorder";
import type { StatisticsMetaData } from "../../src/data/recorder";
import { createMockComputedStyle } from "../fixtures/computed-style";
import { createMockHass } from "../fixtures/hass";
import { FIXED_EPOCH_MS } from "../fixtures/history-states";
import { generateStatistics } from "../fixtures/statistics";
const computedStyle = createMockComputedStyle();
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
const buildMetadata = (
ids: string[],
unit: string,
hasSum: boolean
): Record<string, StatisticsMetaData> =>
Object.fromEntries(
ids.map((id) => [
id,
{
statistic_id: id,
statistics_unit_of_measurement: unit,
source: "recorder",
name: null,
has_sum: hasSum,
mean_type: hasSum
? StatisticMeanType.NONE
: StatisticMeanType.ARITHMETIC,
unit_class: hasSum ? "energy" : "temperature",
},
])
);
const meanIds = Array.from({ length: 5 }, (_, i) => `sensor.temperature_${i}`);
const sumIds = Array.from({ length: 5 }, (_, i) => `sensor.energy_${i}`);
const meanMonth = generateStatistics(1, {
ids: meanIds,
period: "hour",
days: 31,
});
const meanWeek5min = generateStatistics(2, {
ids: meanIds,
period: "5minute",
days: 7,
});
const sumMonth = generateStatistics(3, {
ids: sumIds,
period: "hour",
days: 31,
sumStatistics: true,
});
const base = {
hass,
computedStyle,
now: new Date(FIXED_EPOCH_MS + 31 * dayMs),
hiddenStats: new Set<string>(),
hideLegend: false,
} as const;
describe("generateStatisticsChartData", () => {
bench(
"line with bands, hourly month, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: meanMonth,
statisticsMetaData: buildMetadata(meanIds, "°C", false),
statTypes: ["mean", "min", "max"],
chartType: "line",
period: "hour",
});
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"line with bands, 5-minute week, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: meanWeek5min,
statisticsMetaData: buildMetadata(meanIds, "°C", false),
statTypes: ["mean", "min", "max"],
chartType: "line",
period: "5minute",
});
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"stacked bars, hourly month sums, 5 entities",
() => {
generateStatisticsChartData({
...base,
statisticsData: sumMonth,
statisticsMetaData: buildMetadata(sumIds, "kWh", true),
statTypes: ["change"],
chartType: "bar-stack",
period: "hour",
});
},
{ time: 1000, warmupIterations: 2 }
);
});
+71
View File
@@ -0,0 +1,71 @@
import { bench, describe } from "vitest";
import {
computeHistory,
convertStatisticsToHistory,
mergeHistoryResults,
} from "../../src/data/history";
import { createMockHass, mockLocalize } from "../fixtures/hass";
import {
FIXED_EPOCH_MS,
generateNumericSensorStates,
} from "../fixtures/history-states";
import { generateStatistics } from "../fixtures/statistics";
const SENSOR_NUMERIC_DEVICE_CLASSES = ["power", "energy", "temperature"];
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
const idsFor = (count: number) =>
Array.from({ length: count }, (_, i) => `sensor.temperature_${i}`);
const hourlyMonth = generateStatistics(1, {
ids: idsFor(5),
period: "hour",
days: 31,
});
const fiveMinuteWeek = generateStatistics(2, {
ids: idsFor(5),
period: "5minute",
days: 7,
});
const ltsResult = convertStatisticsToHistory(
hass,
generateStatistics(3, { ids: idsFor(10), period: "hour", days: 31 }),
idsFor(10),
SENSOR_NUMERIC_DEVICE_CLASSES
);
const recentStates = {};
idsFor(10).forEach((id, i) => {
recentStates[id] = generateNumericSensorStates(100 + i, {
count: 2_000,
startMs: FIXED_EPOCH_MS + 31 * dayMs,
});
});
const historyResult = computeHistory(
hass,
recentStates,
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
);
describe("convertStatisticsToHistory", () => {
bench("hourly statistics, month, 5 entities", () => {
convertStatisticsToHistory(hass, hourlyMonth, idsFor(5), [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
});
bench("5-minute statistics, week, 5 entities", () => {
convertStatisticsToHistory(hass, fiveMinuteWeek, idsFor(5), [
...SENSOR_NUMERIC_DEVICE_CLASSES,
]);
});
});
describe("mergeHistoryResults", () => {
bench("month of LTS + recent history, 10 entities", () => {
mergeHistoryResults(historyResult, ltsResult);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { downSampleLineData } from "../../../src/components/chart/down-sample";
import { digestResult } from "../../fixtures/digest";
import { FIXED_EPOCH_MS, SCALES } from "../../fixtures/history-states";
import { createSeededRandom } from "../../fixtures/random";
const generatePoints = (
seed: number,
count: number,
intervalMs = 30_000
): [number, number][] => {
const random = createSeededRandom(seed);
const points: [number, number][] = [];
let y = 100;
for (let i = 0; i < count; i++) {
y = Math.max(0, y + (random() - 0.5) * 10);
points.push([FIXED_EPOCH_MS + i * intervalMs, Number(y.toFixed(3))]);
}
return points;
};
const toObjectPoints = (points: [number, number][]) =>
points.map((value) => ({ value }));
describe("downSampleLineData", () => {
it("returns empty array for undefined data", () => {
expect(downSampleLineData(undefined, 100)).toEqual([]);
});
it("returns input unchanged when below maxDetails", () => {
const points = generatePoints(1, 50);
expect(downSampleLineData(points, 100)).toBe(points);
});
it("skips points with non-finite coordinates", () => {
const points = generatePoints(2, 200);
points[10] = [points[10][0], NaN];
points[20] = [NaN, points[20][1]];
const result = downSampleLineData(points, 50);
expect(result).not.toContain(points[10]);
expect(result).not.toContain(points[20]);
});
it("min/max mode only returns points from the input", () => {
const points = generatePoints(3, 500);
const result = downSampleLineData(points, 50);
const inputSet = new Set(points);
expect(result.length).toBeLessThanOrEqual(points.length);
result.forEach((point) => expect(inputSet.has(point)).toBe(true));
});
it("min/max mode preserves x-order for sorted input", () => {
const points = generatePoints(4, 1000);
const result = downSampleLineData(points, 50);
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
});
it("min/max mode matches characterization snapshot", () => {
expect(downSampleLineData(generatePoints(5, 300), 40)).toMatchSnapshot();
});
it("mean mode matches characterization snapshot", () => {
expect(
downSampleLineData(generatePoints(5, 300), 40, undefined, undefined, true)
).toMatchSnapshot();
});
it("object-shaped points match characterization snapshot", () => {
expect(
downSampleLineData(toObjectPoints(generatePoints(6, 300)), 40)
).toMatchSnapshot();
});
it("explicit minX/maxX bounds match characterization snapshot", () => {
const points = generatePoints(7, 300);
const minX = points[0][0] - 60_000;
const maxX = points[points.length - 1][0] + 60_000;
expect(downSampleLineData(points, 40, minX, maxX)).toMatchSnapshot();
});
it("small scale digest is stable", () => {
expect(
digestResult(downSampleLineData(generatePoints(8, SCALES.small), 500))
).toMatchSnapshot();
});
it("large scale digest is stable", () => {
expect(
digestResult(downSampleLineData(generatePoints(9, SCALES.large), 500))
).toMatchSnapshot();
});
it("large scale mean-mode digest is stable", () => {
expect(
digestResult(
downSampleLineData(
generatePoints(10, SCALES.large),
500,
undefined,
undefined,
true
)
)
).toMatchSnapshot();
});
});
@@ -0,0 +1,138 @@
/**
* Characterization tests pinning the exact output of the history line chart
* data transform. Do NOT update these snapshots to make an optimization
* pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateStateHistoryChartLineData } from "../../../src/components/chart/state-history-chart-line-data";
import {
computeHistory,
convertStatisticsToHistory,
} from "../../../src/data/history";
import { createMockComputedStyle } from "../../fixtures/computed-style";
import { digestResult } from "../../fixtures/digest";
import {
createMockEntityState,
createMockHass,
mockLocalize,
} from "../../fixtures/hass";
import {
FIXED_EPOCH_MS,
generateClimateStates,
generateMixedHistory,
generateNumericSensorStates,
} from "../../fixtures/history-states";
import { generateStatistics } from "../../fixtures/statistics";
const SENSOR_NUMERIC_DEVICE_CLASSES = ["power", "energy", "temperature"];
const computedStyles = createMockComputedStyle();
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
/** Run history fixtures through computeHistory to get LineChartEntity[] */
const toLineChartEntities = (history) =>
computeHistory(
hass,
history,
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
).line.flatMap((unit) => unit.data);
describe("generateStateHistoryChartLineData", () => {
const baseParams = {
hass,
computedStyles,
showNames: true,
endTime: new Date(FIXED_EPOCH_MS + dayMs),
now: new Date(FIXED_EPOCH_MS + dayMs + 60 * 60 * 1000),
} as const;
it("returns undefined for empty data", () => {
expect(
generateStateHistoryChartLineData({ ...baseParams, data: [] })
).toBeUndefined();
});
it("matches snapshot for numeric sensors", () => {
const data = toLineChartEntities({
"sensor.power_meter": generateNumericSensorStates(1, { count: 40 }),
"sensor.power_solar": generateNumericSensorStates(2, { count: 40 }),
});
expect(
generateStateHistoryChartLineData({ ...baseParams, data })
).toMatchSnapshot();
});
it("matches snapshot for a climate entity with hvac action modes", () => {
const data = toLineChartEntities({
"climate.thermostat": generateClimateStates(3, { count: 40 }),
});
expect(
generateStateHistoryChartLineData({ ...baseParams, data })
).toMatchSnapshot();
});
it("appends current state for sensors when endTime is now", () => {
const data = toLineChartEntities({
"sensor.power_meter": generateNumericSensorStates(4, { count: 20 }),
});
const endTime = new Date(FIXED_EPOCH_MS + dayMs);
expect(
generateStateHistoryChartLineData({
...baseParams,
hass: createMockHass({
"sensor.power_meter": createMockEntityState(
"sensor.power_meter",
"123.4",
{ unit_of_measurement: "W" }
),
}),
data,
endTime,
now: new Date(endTime.getTime() + 500),
})
).toMatchSnapshot();
});
it("builds a visual map for entities backed by statistics", () => {
const statsHistory = convertStatisticsToHistory(
hass,
generateStatistics(5, {
ids: ["sensor.power_meter"],
period: "hour",
days: 1,
}),
["sensor.power_meter"],
SENSOR_NUMERIC_DEVICE_CLASSES
);
const recent = toLineChartEntities({
"sensor.power_meter": generateNumericSensorStates(6, {
count: 20,
startMs: FIXED_EPOCH_MS + dayMs - 60 * 60 * 1000,
}),
});
const data = [
{
...recent[0],
statistics: statsHistory.line[0].data[0].statistics,
},
];
const result = generateStateHistoryChartLineData({ ...baseParams, data });
expect(result?.visualMap).toBeDefined();
expect(result).toMatchSnapshot();
});
it("large mixed payload digest is stable", () => {
const data = toLineChartEntities(generateMixedHistory(42, "large"));
expect(
digestResult(
generateStateHistoryChartLineData({
...baseParams,
endTime: new Date(FIXED_EPOCH_MS + 30 * dayMs),
data,
})
)
).toMatchSnapshot();
});
});
@@ -0,0 +1,196 @@
/**
* Characterization tests pinning the exact output of the statistics chart
* data transform. Do NOT update these snapshots to make an optimization
* pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { generateStatisticsChartData } from "../../../src/components/chart/statistics-chart-data";
import { StatisticMeanType } from "../../../src/data/recorder";
import type { StatisticsMetaData } from "../../../src/data/recorder";
import { createMockComputedStyle } from "../../fixtures/computed-style";
import { digestResult } from "../../fixtures/digest";
import { createMockEntityState, createMockHass } from "../../fixtures/hass";
import { FIXED_EPOCH_MS } from "../../fixtures/history-states";
import { generateStatistics } from "../../fixtures/statistics";
const computedStyle = createMockComputedStyle();
const dayMs = 24 * 60 * 60 * 1000;
const now = new Date(FIXED_EPOCH_MS + 7 * dayMs);
const buildMetadata = (
ids: string[],
unit = "°C",
hasSum = false
): Record<string, StatisticsMetaData> =>
Object.fromEntries(
ids.map((id) => [
id,
{
statistic_id: id,
statistics_unit_of_measurement: unit,
source: "recorder",
name: null,
has_sum: hasSum,
mean_type: hasSum
? StatisticMeanType.NONE
: StatisticMeanType.ARITHMETIC,
unit_class: hasSum ? "energy" : "temperature",
},
])
);
describe("generateStatisticsChartData", () => {
const meanIds = ["sensor.temp_indoor", "sensor.temp_outdoor"];
const sumIds = ["sensor.energy_a", "sensor.energy_b"];
const baseParams = {
hass: createMockHass(),
computedStyle,
now,
hiddenStats: new Set<string>(),
hideLegend: false,
} as const;
it("returns undefined for empty statistics", () => {
expect(
generateStatisticsChartData({
...baseParams,
statisticsData: {},
statisticsMetaData: {},
statTypes: ["mean"],
chartType: "line",
})
).toBeUndefined();
});
it("matches snapshot for a line chart with min/mean/max bands", () => {
expect(
generateStatisticsChartData({
...baseParams,
statisticsData: generateStatistics(1, {
ids: meanIds,
period: "hour",
days: 1,
}),
statisticsMetaData: buildMetadata(meanIds),
statTypes: ["mean", "min", "max"],
chartType: "line",
period: "hour",
})
).toMatchSnapshot();
});
it("matches snapshot for a bar chart with sum statistics", () => {
expect(
generateStatisticsChartData({
...baseParams,
statisticsData: generateStatistics(2, {
ids: sumIds,
period: "hour",
days: 1,
sumStatistics: true,
}),
statisticsMetaData: buildMetadata(sumIds, "kWh", true),
statTypes: ["sum"],
chartType: "bar",
period: "hour",
})
).toMatchSnapshot();
});
it("matches snapshot for a stacked bar chart with change statistics", () => {
expect(
generateStatisticsChartData({
...baseParams,
statisticsData: generateStatistics(3, {
ids: sumIds,
period: "day",
days: 7,
sumStatistics: true,
}),
statisticsMetaData: buildMetadata(sumIds, "kWh", true),
statTypes: ["change"],
chartType: "bar-stack",
period: "day",
})
).toMatchSnapshot();
});
it("matches snapshot with a hidden statistic", () => {
expect(
generateStatisticsChartData({
...baseParams,
hiddenStats: new Set([meanIds[0]]),
statisticsData: generateStatistics(4, {
ids: meanIds,
period: "hour",
days: 1,
}),
statisticsMetaData: buildMetadata(meanIds),
statTypes: ["mean", "min", "max"],
chartType: "line",
period: "hour",
})
).toMatchSnapshot();
});
it("appends current state for recent data", () => {
const id = "sensor.temp_indoor";
const recentNow = new Date(FIXED_EPOCH_MS + dayMs + 5 * 60 * 1000);
expect(
generateStatisticsChartData({
...baseParams,
hass: createMockHass({
[id]: createMockEntityState(id, "21.5", {
unit_of_measurement: "°C",
device_class: "temperature",
}),
}),
now: recentNow,
statisticsData: generateStatistics(5, {
ids: [id],
period: "hour",
days: 1,
}),
statisticsMetaData: buildMetadata([id]),
statTypes: ["mean"],
chartType: "line",
period: "hour",
})
).toMatchSnapshot();
});
it("infers the chart unit from metadata", () => {
const result = generateStatisticsChartData({
...baseParams,
statisticsData: generateStatistics(6, {
ids: meanIds,
period: "hour",
days: 1,
}),
statisticsMetaData: buildMetadata(meanIds),
statTypes: ["mean"],
chartType: "line",
});
expect(result?.unit).toBe("°C");
});
it("large dataset digest is stable", () => {
expect(
digestResult(
generateStatisticsChartData({
...baseParams,
statisticsData: generateStatistics(7, {
ids: meanIds,
period: "5minute",
days: 31,
}),
statisticsMetaData: buildMetadata(meanIds),
statTypes: ["mean", "min", "max"],
chartType: "line",
period: "5minute",
})
)
).toMatchSnapshot();
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
/**
* Characterization tests pinning the exact current output of the energy
* dashboard data processing. These exist so performance optimizations can be
* verified to leave behavior bit-identical see test/benchmarks/README.md.
*
* Do NOT update these snapshots to make an optimization pass; an output
* change is a behavior change and must be escalated instead.
*/
import { describe, expect, it } from "vitest";
import {
computeConsumptionData,
computeConsumptionSingle,
getSummedData,
} from "../../src/data/energy";
import { digestResult } from "../fixtures/digest";
import {
generateEnergyData,
generateEnergyPreferences,
} from "../fixtures/energy";
describe("getSummedData characterization", () => {
it("matches snapshot for a grid-only setup", () => {
const data = generateEnergyData(1, {
days: 1,
prefs: generateEnergyPreferences({ grid: true }),
});
expect(getSummedData(data)).toMatchSnapshot();
});
it("matches snapshot for grid + solar", () => {
const data = generateEnergyData(2, {
days: 1,
prefs: generateEnergyPreferences({ grid: true, solar: true }),
});
expect(getSummedData(data)).toMatchSnapshot();
});
it("matches snapshot for a full setup with compare data", () => {
const data = generateEnergyData(3, { days: 1, compare: true });
expect(getSummedData(data)).toMatchSnapshot();
});
it("digest is stable for a month of 5-minute data", () => {
const data = generateEnergyData(4, { days: 31, period: "hour" });
expect(digestResult(getSummedData(data).summedData)).toMatchSnapshot();
});
});
describe("computeConsumptionData characterization", () => {
it("matches snapshot for a full battery + solar setup", () => {
const data = generateEnergyData(5, { days: 1 });
const { summedData } = getSummedData(data);
expect(computeConsumptionData(summedData, undefined)).toMatchSnapshot();
});
it("matches snapshot with compare data", () => {
const data = generateEnergyData(6, { days: 1, compare: true });
const { summedData, compareSummedData } = getSummedData(data);
expect(
computeConsumptionData(summedData, compareSummedData)
).toMatchSnapshot();
});
it("digest is stable for a month of hourly data", () => {
const data = generateEnergyData(7, { days: 31, period: "hour" });
const { summedData } = getSummedData(data);
expect(
digestResult(computeConsumptionData(summedData, undefined).consumption)
).toMatchSnapshot();
});
});
describe("computeConsumptionSingle characterization", () => {
it("handles grid-only consumption", () => {
expect(
computeConsumptionSingle({
from_grid: 5,
to_grid: undefined,
solar: undefined,
to_battery: undefined,
from_battery: undefined,
})
).toMatchSnapshot();
});
it("handles solar excess returned to grid", () => {
expect(
computeConsumptionSingle({
from_grid: 1,
to_grid: 4,
solar: 8,
to_battery: undefined,
from_battery: undefined,
})
).toMatchSnapshot();
});
it("handles a full battery flow", () => {
expect(
computeConsumptionSingle({
from_grid: 2,
to_grid: 1,
solar: 6,
to_battery: 3,
from_battery: 2,
})
).toMatchSnapshot();
});
it("handles battery charged from grid", () => {
expect(
computeConsumptionSingle({
from_grid: 6,
to_grid: 0,
solar: 0,
to_battery: 4,
from_battery: 0,
})
).toMatchSnapshot();
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* Characterization tests pinning the exact current output of the history
* data-processing pipeline. These exist so performance optimizations can be
* verified to leave behavior bit-identical see test/benchmarks/README.md.
*
* Do NOT update these snapshots to make an optimization pass; an output
* change is a behavior change and must be escalated instead.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HistoryStream,
computeHistory,
convertStatisticsToHistory,
mergeHistoryResults,
} from "../../src/data/history";
import { digestResult } from "../fixtures/digest";
import {
FIXED_EPOCH_MS,
generateBinarySensorStates,
generateClimateStates,
generateMixedHistory,
generateNumericSensorStates,
} from "../fixtures/history-states";
import {
createMockEntityState,
createMockHass,
mockLocalize,
} from "../fixtures/hass";
import { generateStatistics } from "../fixtures/statistics";
const SENSOR_NUMERIC_DEVICE_CLASSES = ["power", "energy", "temperature"];
const buildTinyHistory = () => ({
"sensor.power_meter": generateNumericSensorStates(1, { count: 40 }),
"binary_sensor.motion_hall": generateBinarySensorStates(2, { count: 30 }),
"climate.thermostat": generateClimateStates(3, { count: 30 }),
});
describe("computeHistory characterization", () => {
const hass = createMockHass({
"sensor.power_meter": createMockEntityState("sensor.power_meter", "150.0", {
unit_of_measurement: "W",
device_class: "power",
state_class: "measurement",
friendly_name: "Power meter",
}),
"climate.thermostat": createMockEntityState("climate.thermostat", "heat", {
friendly_name: "Thermostat",
current_temperature: 21,
temperature: 21,
}),
});
it("matches snapshot for a mixed payload", () => {
expect(
computeHistory(
hass,
buildTinyHistory(),
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
)
).toMatchSnapshot();
});
it("matches snapshot with splitDeviceClasses enabled", () => {
expect(
computeHistory(
hass,
buildTinyHistory(),
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES,
true
)
).toMatchSnapshot();
});
it("matches snapshot with forceNumeric enabled", () => {
expect(
computeHistory(
hass,
buildTinyHistory(),
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES,
false,
true
)
).toMatchSnapshot();
});
it("falls back to current state for entities without history", () => {
expect(
computeHistory(
hass,
{},
["sensor.power_meter", "sensor.not_present"],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
)
).toMatchSnapshot();
});
it("large mixed payload digest is stable", () => {
expect(
digestResult(
computeHistory(
hass,
generateMixedHistory(42, "large"),
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
)
)
).toMatchSnapshot();
});
});
describe("HistoryStream characterization", () => {
afterEach(() => {
vi.useRealTimers();
});
it("merges an incremental message and purges expired states", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const hoursToShow = 2;
const stream = new HistoryStream(createMockHass(), hoursToShow);
const windowStartMs = nowMs - hoursToShow * 60 * 60 * 1000;
// Initial chunk: 1 hour of data starting just inside the window,
// plus older states that should be purged on the next message.
const initial = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(7, {
count: 200,
startMs: windowStartMs - 30 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
},
});
expect(digestResult(initial)).toMatchSnapshot("initial");
// Incremental update with recent states
const incremental = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(8, {
count: 10,
startMs: nowMs - 5 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
},
});
expect(incremental).toMatchSnapshot("incremental");
});
it("handles multiple entities", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const stream = new HistoryStream(createMockHass(), 1);
const result = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(9, {
count: 30,
startMs: nowMs - 30 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
"binary_sensor.motion_hall": generateBinarySensorStates(10, {
count: 20,
startMs: nowMs - 30 * 60 * 1000,
intervalMs: 90 * 1000,
jitter: 0,
}),
},
});
expect(result).toMatchSnapshot();
});
});
describe("convertStatisticsToHistory characterization", () => {
const hass = createMockHass();
it("converts mean-based statistics", () => {
const statistics = generateStatistics(11, {
ids: ["sensor.temperature_indoor"],
period: "hour",
days: 2,
});
expect(
convertStatisticsToHistory(
hass,
statistics,
["sensor.temperature_indoor"],
SENSOR_NUMERIC_DEVICE_CLASSES
)
).toMatchSnapshot();
});
it("converts sum-based statistics and respects id ordering", () => {
const statistics = generateStatistics(12, {
ids: ["sensor.energy_a", "sensor.energy_b"],
period: "hour",
days: 1,
sumStatistics: true,
});
expect(
convertStatisticsToHistory(
hass,
statistics,
["sensor.energy_b", "sensor.energy_a"],
SENSOR_NUMERIC_DEVICE_CLASSES,
true
)
).toMatchSnapshot();
});
});
describe("mergeHistoryResults characterization", () => {
it("merges recent history with long-term statistics", () => {
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
const ltsResult = convertStatisticsToHistory(
hass,
generateStatistics(13, {
ids: ["sensor.power_meter"],
period: "hour",
days: 2,
}),
["sensor.power_meter"],
SENSOR_NUMERIC_DEVICE_CLASSES
);
const historyResult = computeHistory(
hass,
{
"sensor.power_meter": generateNumericSensorStates(14, {
count: 50,
startMs: FIXED_EPOCH_MS + 2 * dayMs,
}),
},
[],
mockLocalize,
SENSOR_NUMERIC_DEVICE_CLASSES
);
expect(mergeHistoryResults(historyResult, ltsResult)).toMatchSnapshot();
expect(mergeHistoryResults(historyResult, undefined)).toBe(historyResult);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Fake CSSStyleDeclaration for code that resolves theme colors via
* `getComputedStyle(this)` (e.g. getGraphColorByIndex). Returns a fixed,
* deterministic palette so series colors are stable in tests and benches
* without a DOM.
*/
const FIXED_THEME: Record<string, string> = {
"--graph-color-1": "#4269d0",
"--graph-color-2": "#f4bd4a",
"--graph-color-3": "#ff725c",
"--graph-color-4": "#6cc5b0",
"--graph-color-5": "#a463f2",
"--graph-color-6": "#ff8ab7",
"--graph-color-7": "#9c6b4e",
"--graph-color-8": "#97bbf5",
"--primary-color": "#03a9f4",
"--accent-color": "#ff9800",
"--primary-text-color": "#212121",
"--secondary-text-color": "#727272",
"--disabled-text-color": "#bdbdbd",
"--error-color": "#db4437",
"--warning-color": "#ffa600",
"--success-color": "#43a047",
"--info-color": "#039be5",
"--state-climate-heat-color": "#ff8100",
"--state-climate-cool-color": "#2b9af9",
"--state-climate-idle-color": "#7f848e",
"--state-climate-off-color": "#80868b",
};
export const createMockComputedStyle = (
overrides: Record<string, string> = {}
): CSSStyleDeclaration => {
const theme = { ...FIXED_THEME, ...overrides };
return {
getPropertyValue: (property: string) => theme[property] ?? "",
} as CSSStyleDeclaration;
};
+57
View File
@@ -0,0 +1,57 @@
/**
* Structural checksum for large transform outputs. Snapshotting a 100k-point
* result verbatim would create megabyte snapshot files; this digest is small
* but still detects any numeric or structural drift, including changes in
* floating-point summation order.
*/
interface Digest {
type: string;
size?: number;
keys?: string[];
numberCount?: number;
numberSum?: string;
first?: unknown;
last?: unknown;
}
const collectNumbers = (
value: unknown,
acc: { count: number; sum: number }
) => {
if (typeof value === "number") {
if (Number.isFinite(value)) {
acc.count++;
acc.sum += value;
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectNumbers(item, acc);
}
return;
}
if (value && typeof value === "object") {
for (const key of Object.keys(value)) {
collectNumbers((value as Record<string, unknown>)[key], acc);
}
}
};
export const digestResult = (value: unknown): Digest => {
const acc = { count: 0, sum: 0 };
collectNumbers(value, acc);
const digest: Digest = {
type: Array.isArray(value) ? "array" : typeof value,
numberCount: acc.count,
numberSum: acc.sum.toPrecision(12),
};
if (Array.isArray(value)) {
digest.size = value.length;
digest.first = value[0];
digest.last = value[value.length - 1];
} else if (value && typeof value === "object") {
digest.keys = Object.keys(value).sort();
}
return digest;
};
+150
View File
@@ -0,0 +1,150 @@
/**
* Deterministic fixtures for energy dashboard data processing
* (getSummedData / computeConsumptionData in src/data/energy.ts).
*
* Only the fields those functions read (`prefs.energy_sources`, `stats`,
* `statsCompare`) are populated with meaningful data.
*/
import type {
EnergyData,
EnergyPreferences,
EnergySource,
} from "../../src/data/energy";
import { FIXED_EPOCH_MS } from "./history-states";
import { generateStatistics } from "./statistics";
export interface EnergyPreferencesOptions {
grid?: boolean;
solar?: boolean;
battery?: boolean;
gas?: boolean;
water?: boolean;
}
export const generateEnergyPreferences = (
options: EnergyPreferencesOptions = {
grid: true,
solar: true,
battery: true,
gas: true,
water: true,
}
): EnergyPreferences => {
const sources: EnergySource[] = [];
if (options.grid) {
sources.push({
type: "grid",
stat_energy_from: "sensor.grid_consumption",
stat_energy_to: "sensor.grid_return",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
stat_compensation: null,
entity_energy_price_export: null,
number_energy_price_export: null,
cost_adjustment_day: 0,
});
}
if (options.solar) {
sources.push({
type: "solar",
stat_energy_from: "sensor.solar_production",
config_entry_solar_forecast: null,
});
}
if (options.battery) {
sources.push({
type: "battery",
stat_energy_from: "sensor.battery_discharge",
stat_energy_to: "sensor.battery_charge",
});
}
if (options.gas) {
sources.push({
type: "gas",
stat_energy_from: "sensor.gas_consumption",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
});
}
if (options.water) {
sources.push({
type: "water",
stat_energy_from: "sensor.water_consumption",
stat_cost: null,
entity_energy_price: null,
number_energy_price: null,
});
}
return {
energy_sources: sources,
device_consumption: [],
device_consumption_water: [],
};
};
export interface EnergyDataOptions {
days: number;
period?: "5minute" | "hour" | "day";
compare?: boolean;
prefs?: EnergyPreferences;
}
const statisticIdsForPrefs = (prefs: EnergyPreferences): string[] => {
const ids: string[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "grid") {
if (source.stat_energy_from) ids.push(source.stat_energy_from);
if (source.stat_energy_to) ids.push(source.stat_energy_to);
} else if (source.type === "battery") {
ids.push(source.stat_energy_from, source.stat_energy_to);
} else {
ids.push(source.stat_energy_from);
}
}
return ids;
};
/**
* Builds an `EnergyData`-shaped object with the fields read by
* getSummedData/computeConsumptionData. The result is cast from a partial:
* unrelated fields (info, statsMetadata, units) are stubbed.
*/
export const generateEnergyData = (
seed: number,
options: EnergyDataOptions
): EnergyData => {
const { days, period = "hour", compare = false } = options;
const prefs = options.prefs ?? generateEnergyPreferences();
const ids = statisticIdsForPrefs(prefs);
const dayMs = 24 * 60 * 60 * 1000;
const stats = generateStatistics(seed, {
ids,
period,
days,
sumStatistics: true,
});
const statsCompare = compare
? generateStatistics(seed + 5000, {
ids,
period,
days,
startMs: FIXED_EPOCH_MS - days * dayMs,
sumStatistics: true,
})
: ({} as EnergyData["statsCompare"]);
return {
start: new Date(FIXED_EPOCH_MS),
end: new Date(FIXED_EPOCH_MS + days * dayMs),
prefs,
info: { cost_sensors: {}, solar_forecast_domains: [] },
stats,
statsMetadata: {},
statsCompare,
waterUnit: "L",
gasUnit: "m³",
} as EnergyData;
};
+63
View File
@@ -0,0 +1,63 @@
/**
* Deterministic `HomeAssistant` stub covering exactly what the chart data
* transforms read: states, entities, locale, config, localize, and entity
* state formatting. Everything is stable across runs.
*/
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import type { FrontendLocaleData } from "../../src/data/translation";
import { demoConfig } from "../../src/fake_data/demo_config";
import type { HomeAssistant } from "../../src/types";
import { FIXED_EPOCH_MS } from "./history-states";
export const mockLocale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.comma_decimal,
time_format: TimeFormat.am_pm,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
/** Localize stub: returns the key plus any args, deterministically. */
export const mockLocalize: LocalizeFunc = (key, args?) =>
args ? `${key}: ${JSON.stringify(args)}` : (key as string);
export const createMockEntityState = (
entityId: string,
state: string,
attributes: Record<string, any> = {}
): HassEntity => ({
entity_id: entityId,
state,
attributes,
last_changed: new Date(FIXED_EPOCH_MS).toISOString(),
last_updated: new Date(FIXED_EPOCH_MS).toISOString(),
context: { id: "fixture", parent_id: null, user_id: null },
});
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
({
states,
entities: {},
devices: {},
areas: {},
floors: {},
config: demoConfig,
locale: mockLocale,
language: "en",
localize: mockLocalize,
formatEntityState: (stateObj: HassEntity, state?: string) =>
state ?? stateObj.state,
formatEntityAttributeValue: (stateObj: HassEntity, attribute: string) =>
String(stateObj.attributes[attribute]),
formatEntityAttributeName: (_stateObj: HassEntity, attribute: string) =>
attribute,
}) as unknown as HomeAssistant;
+193
View File
@@ -0,0 +1,193 @@
/**
* Deterministic generators for the `HistoryStates` wire format consumed by
* `computeHistory` and `HistoryStream` (src/data/history.ts).
*
* Timestamps (`lu`/`lc`) are python time in SECONDS, matching the websocket
* API. Unless stated otherwise, fixtures are anchored at FIXED_EPOCH_MS so
* outputs are stable across runs; HistoryStream scenarios must instead anchor
* relative to the (possibly faked) current time via the `startMs` option.
*/
import type { EntityHistoryState, HistoryStates } from "../../src/data/history";
import { createSeededRandom } from "./random";
export const FIXED_EPOCH_MS = Date.UTC(2024, 0, 1, 0, 0, 0);
export const SCALES = {
small: 1_000,
medium: 10_000,
large: 100_000,
} as const;
export type ScaleName = keyof typeof SCALES;
interface GeneratorOptions {
/** Number of states to generate */
count: number;
/** Timestamp of the first state, in milliseconds */
startMs?: number;
/** Base interval between states, in milliseconds */
intervalMs?: number;
/** Random jitter applied to each interval, 0..1 fraction of intervalMs */
jitter?: number;
}
const resolveOptions = (options: GeneratorOptions) => ({
startMs: FIXED_EPOCH_MS,
intervalMs: 30_000,
jitter: 0.2,
...options,
});
/**
* Random-walk numeric sensor history. Includes repeated values (so some
* states carry `lc` distinct from `lu`) and occasional unavailable/unknown
* states to exercise sanitization paths.
*/
export const generateNumericSensorStates = (
seed: number,
options: GeneratorOptions,
attributes: Record<string, any> = {
unit_of_measurement: "W",
device_class: "power",
state_class: "measurement",
}
): EntityHistoryState[] => {
const { count, startMs, intervalMs, jitter } = resolveOptions(options);
const random = createSeededRandom(seed);
const states: EntityHistoryState[] = [];
let value = 100 + random() * 100;
let timeMs = startMs;
let lastChangedMs = startMs;
for (let i = 0; i < count; i++) {
const roll = random();
let s: string;
if (roll < 0.005) {
s = "unavailable";
} else if (roll < 0.0075) {
s = "unknown";
} else if (roll < 0.15 && states.length > 0) {
// Repeat the previous value: lu advances, lc stays
s = states[states.length - 1].s;
} else {
value = Math.max(0, value + (random() - 0.5) * 20);
s = value.toFixed(2);
}
const changed = states.length === 0 || s !== states[states.length - 1].s;
if (changed) {
lastChangedMs = timeMs;
}
const state: EntityHistoryState = {
s,
a: i === 0 ? attributes : {},
lu: timeMs / 1000,
};
if (!changed) {
state.lc = lastChangedMs / 1000;
}
states.push(state);
timeMs += intervalMs * (1 + (random() - 0.5) * 2 * jitter);
}
return states;
};
/** On/off binary sensor history (becomes a timeline entity). */
export const generateBinarySensorStates = (
seed: number,
options: GeneratorOptions
): EntityHistoryState[] => {
const { count, startMs, intervalMs, jitter } = resolveOptions(options);
const random = createSeededRandom(seed);
const states: EntityHistoryState[] = [];
let on = random() > 0.5;
let timeMs = startMs;
for (let i = 0; i < count; i++) {
on = random() < 0.4 ? !on : on;
states.push({
s: on ? "on" : "off",
a: i === 0 ? { device_class: "motion" } : {},
lu: timeMs / 1000,
});
timeMs += intervalMs * (1 + (random() - 0.5) * 2 * jitter);
}
return states;
};
/**
* Climate entity history with churning attributes; exercises the
* attribute-keeping path (LINE_ATTRIBUTES_TO_KEEP) and the line+timeline
* split for climate domains.
*/
export const generateClimateStates = (
seed: number,
options: GeneratorOptions
): EntityHistoryState[] => {
const { count, startMs, intervalMs, jitter } = resolveOptions(options);
const random = createSeededRandom(seed);
const states: EntityHistoryState[] = [];
const modes = ["heat", "cool", "off"] as const;
let mode: (typeof modes)[number] = "heat";
let current = 19 + random() * 4;
let target = 21;
let timeMs = startMs;
for (let i = 0; i < count; i++) {
if (random() < 0.05) {
mode = modes[Math.floor(random() * modes.length)];
}
if (random() < 0.1) {
target = 18 + Math.floor(random() * 6);
}
current += (random() - 0.5) * 0.6;
states.push({
s: mode,
a: {
temperature: target,
current_temperature: Number(current.toFixed(1)),
hvac_action:
mode === "off" ? "off" : random() > 0.5 ? "heating" : "idle",
},
lu: timeMs / 1000,
});
timeMs += intervalMs * (1 + (random() - 0.5) * 2 * jitter);
}
return states;
};
/**
* Multi-entity payload approximating a real history subscription:
* numeric sensors, binary sensors, and one climate entity. The scale is the
* approximate TOTAL number of states across all entities.
*/
export const generateMixedHistory = (
seed: number,
scale: ScaleName,
startMs = FIXED_EPOCH_MS
): HistoryStates => {
const total = SCALES[scale];
const numericSensors = 4;
const binarySensors = 2;
const perEntity = Math.floor(total / (numericSensors + binarySensors + 1));
const history: HistoryStates = {};
for (let i = 0; i < numericSensors; i++) {
history[`sensor.power_${i}`] = generateNumericSensorStates(seed + i, {
count: perEntity,
startMs,
});
}
for (let i = 0; i < binarySensors; i++) {
history[`binary_sensor.motion_${i}`] = generateBinarySensorStates(
seed + 100 + i,
{ count: perEntity, startMs }
);
}
history["climate.thermostat"] = generateClimateStates(seed + 200, {
count: perEntity,
startMs,
});
return history;
};
+18
View File
@@ -0,0 +1,18 @@
/**
* Deterministic seeded PRNG (mulberry32) for reproducible fixtures.
* The same seed always yields the same sequence, so characterization
* tests and benchmarks operate on identical data across runs.
*/
/* eslint-disable no-bitwise */
export type SeededRandom = () => number;
export const createSeededRandom = (seed: number): SeededRandom => {
let a = seed >>> 0;
return () => {
a += 0x6d2b79f5;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
+73
View File
@@ -0,0 +1,73 @@
/**
* Deterministic generators for the `Statistics` format (src/data/recorder.ts).
* Anchored at FIXED_EPOCH_MS; timestamps are milliseconds.
*/
import type { Statistics, StatisticValue } from "../../src/data/recorder";
import { FIXED_EPOCH_MS } from "./history-states";
import { createSeededRandom } from "./random";
const PERIOD_MS = {
"5minute": 5 * 60 * 1000,
hour: 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
} as const;
export interface StatisticsOptions {
ids: string[];
period: keyof typeof PERIOD_MS;
days: number;
startMs?: number;
/** Probability that a period is missing entirely (creates gaps) */
gapChance?: number;
/** Generate sum/change (energy-style) instead of mean/min/max */
sumStatistics?: boolean;
}
export const generateStatistics = (
seed: number,
options: StatisticsOptions
): Statistics => {
const {
ids,
period,
days,
startMs = FIXED_EPOCH_MS,
gapChance = 0.02,
sumStatistics = false,
} = options;
const periodMs = PERIOD_MS[period];
const count = Math.floor((days * 24 * 60 * 60 * 1000) / periodMs);
const statistics: Statistics = {};
ids.forEach((id, idIndex) => {
const random = createSeededRandom(seed + idIndex * 1000);
const values: StatisticValue[] = [];
let level = 20 + random() * 10;
let sum = 0;
for (let i = 0; i < count; i++) {
if (random() < gapChance) {
continue;
}
const start = startMs + i * periodMs;
const value: StatisticValue = { start, end: start + periodMs };
if (sumStatistics) {
const change = random() * 2;
sum += change;
value.change = Number(change.toFixed(3));
value.sum = Number(sum.toFixed(3));
value.state = Number(sum.toFixed(3));
} else {
level = Math.max(0, level + (random() - 0.5) * 4);
const spread = random() * 3;
value.mean = Number(level.toFixed(3));
value.min = Number((level - spread).toFixed(3));
value.max = Number((level + spread).toFixed(3));
}
values.push(value);
}
statistics[id] = values;
});
return statistics;
};
@@ -2,6 +2,7 @@ import { assert, describe, it } from "vitest";
import type { BarSeriesOption, LineSeriesOption } from "echarts/charts";
import {
computeStatMidpoint,
fillDataGapsAndRoundCaps,
fillLineGaps,
getCompareTransform,
@@ -571,3 +572,37 @@ describe("getCompareTransform", () => {
assert.equal(result.getDate(), 1);
});
});
describe("computeStatMidpoint", () => {
const start = Date.UTC(2024, 0, 1, 10, 0, 0);
const end = Date.UTC(2024, 0, 1, 11, 0, 0);
it("returns midpoint for hour period", () => {
assert.equal(computeStatMidpoint(start, end, "hour"), (start + end) / 2);
});
it("returns midpoint for 5minute period", () => {
assert.equal(computeStatMidpoint(start, end, "5minute"), (start + end) / 2);
});
it("returns start for day and month periods", () => {
assert.equal(computeStatMidpoint(start, end, "day"), start);
assert.equal(computeStatMidpoint(start, end, "month"), start);
});
it("applies compare transform to start for non-centered periods", () => {
const transform = (ts: Date) => new Date(ts.getTime() + 1000);
assert.equal(
computeStatMidpoint(start, end, "day", transform),
start + 1000
);
});
it("applies compare transform to both ends for centered periods", () => {
const transform = (ts: Date) => new Date(ts.getTime() + 1000);
assert.equal(
computeStatMidpoint(start, end, "hour", transform),
(start + end) / 2 + 1000
);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
// Benchmark configuration for chart data processing transforms.
// Runs in a plain node environment (no jsdom) and sequentially, so timings
// are as stable as possible. See test/benchmarks/README.md.
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
env: {
TZ: "Etc/UTC",
IS_TEST: "true",
},
setupFiles: ["./test/benchmarks/setup.ts"],
fileParallelism: false,
benchmark: {
include: ["test/benchmarks/**/*.bench.ts"],
},
},
});