mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-12 11:22:58 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f637626bf3 | |||
| 16aeb60301 | |||
| 92cee34d6c | |||
| 90e62ab468 | |||
| b75e2e3ed0 | |||
| 4add4509af |
@@ -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
|
||||
|
||||
|
||||
@@ -58,3 +58,4 @@ test/coverage/
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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 })));
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+38
@@ -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;
|
||||
};
|
||||
Vendored
+57
@@ -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;
|
||||
};
|
||||
Vendored
+150
@@ -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;
|
||||
};
|
||||
Vendored
+63
@@ -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;
|
||||
Vendored
+193
@@ -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;
|
||||
};
|
||||
Vendored
+18
@@ -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;
|
||||
};
|
||||
};
|
||||
Vendored
+73
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user