Compare commits

...

1 Commits

Author SHA1 Message Date
Petar Petrov f291207b2f Zero-fill energy chart buckets so sparse data keeps correct axis and bar width 2026-07-03 08:41:49 +03:00
14 changed files with 3588 additions and 78 deletions
+21 -3
View File
@@ -1,8 +1,16 @@
import type { BarSeriesOption } from "echarts/types/dist/shared";
/**
* `extraBuckets` (only used when `stacked`) seeds the bucket union with the
* expected statistics grid so sparse datasets get zero-filled across the whole
* range, including past their last real point. Without it, buckets are only
* derived from the data and trailing buckets are never filled (legacy
* behavior, kept for callers that don't pass a grid).
*/
export function fillDataGapsAndRoundCaps(
datasets: BarSeriesOption[],
stacked = true
stacked = true,
extraBuckets?: number[]
) {
if (!stacked) {
// For non-stacked charts, we can simply apply an overall border to each stack
@@ -44,6 +52,7 @@ export function fillDataGapsAndRoundCaps(
dataset.data!.map((datapoint) => Number(datapoint![0]))
)
.flat()
.concat(extraBuckets ?? [])
)
).sort((a, b) => a - b);
@@ -61,9 +70,18 @@ export function fillDataGapsAndRoundCaps(
const x = item.value?.[0];
const stack = datasets[i].stack ?? "";
if (x === undefined) {
continue;
// Past the end of this dataset's data. Only append trailing buckets
// when an explicit grid was provided; originally-empty datasets
// (e.g. compare placeholders) stay empty either way.
if (
dataPoint !== undefined ||
extraBuckets === undefined ||
!datasets[i].data!.length
) {
continue;
}
}
if (Number(x) !== bucket) {
if (x === undefined || Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
@@ -12,12 +12,14 @@ import {
startOfMonth,
addYears,
addMonths,
addMinutes,
addHours,
startOfDay,
addDays,
subDays,
} from "date-fns";
import type {
BarSeriesOption,
CallbackDataParams,
LineSeriesOption,
TopLevelFormatterParams,
@@ -377,12 +379,97 @@ const PERIOD_MS: Record<string, number> = {
/**
* Offset from a period's start to its midpoint, for centering sub-daily bars
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
* the start. Derived from the period, not from the data, so the first/only
* bucket centers identically to every other bucket. (Previously estimated from
* the gap between the first two entries, which collapsed to 0 with one bucket.)
* the start.
*
* `measuredGap` is the gap between the first two entries, when available. It
* adapts the offset to data that is finer-grained than the nominal period
* (e.g. external forecast data), but is clamped to the nominal period so
* sparse data (gaps between readings) can't inflate the offset, and a lone
* bucket (no gap to measure) still centers on the nominal midpoint.
*/
export function getPeriodMidpointOffset(period: string): number {
return (PERIOD_MS[period] ?? 0) / 2;
export function getPeriodMidpointOffset(
period: string,
measuredGap?: number
): number {
const nominal = PERIOD_MS[period] ?? 0;
return (measuredGap ? Math.min(measuredGap, nominal) : nominal) / 2;
}
/**
* Generate the expected statistics-bucket grid across [start, end) so sparse
* data can be zero-filled. Without a dense grid, ECharts derives the bar band
* width from the minimum gap between data points: sparse data yields
* oversized bars, and a single point makes ECharts expand the time axis by
* ±40% of its span, ignoring the configured min/max.
*
* The grid is anchored on the first real data bucket of a non-compare bar
* series rather than on `start`: recorder buckets are UTC-aligned, so in
* half-hour timezones they don't sit on local period boundaries. Stepping
* from a real bucket keeps generated buckets exactly on the data's grid
* (midpoints for sub-daily periods, period starts otherwise). Returns an
* empty array when there is no data to anchor on.
*/
export function generateFillBuckets(
datasets: BarSeriesOption[],
start: Date,
end: Date,
period: "5minute" | "hour" | "day" | "month"
): number[] {
let anchor: number | undefined;
for (const dataset of datasets) {
if (
dataset.type !== "bar" ||
String(dataset.id).startsWith("compare-") ||
!dataset.data?.length
) {
continue;
}
const first = dataset.data[0];
const value =
first && typeof first === "object" && "value" in first
? first.value
: first;
const x = Number((value as number[])?.[0]);
if (!Number.isNaN(x)) {
anchor = x;
break;
}
}
if (anchor === undefined) {
return [];
}
const anchorDate = new Date(anchor);
// Step relative to the anchor (not iteratively) so month-length clamping
// and DST shifts can't accumulate drift.
const bucketAt = (n: number): number =>
(period === "5minute"
? addMinutes(anchorDate, 5 * n)
: period === "hour"
? addHours(anchorDate, n)
: period === "day"
? addDays(anchorDate, n)
: addMonths(anchorDate, n)
).getTime();
const startMs = start.getTime();
const endMs = end.getTime();
const buckets: number[] = [];
for (let n = 0; ; n--) {
const ts = bucketAt(n);
if (ts < startMs) {
break;
}
buckets.push(ts);
}
for (let n = 1; ; n++) {
const ts = bucketAt(n);
if (ts >= endMs) {
break;
}
buckets.push(ts);
}
return buckets;
}
export interface UntrackedSplit {
@@ -23,6 +23,7 @@ import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
splitUntrackedConsumption,
@@ -238,15 +239,15 @@ function processUntracked(
const sortedTimes = Object.keys(consumptionData.used_total).sort(
(a, b) => Number(a) - Number(b)
);
// Only start timestamps available here, so center sub-daily bars using the
// gap between the first two entries. With a lone first-of-day bucket there is
// no gap to measure, so fall back to the nominal period midpoint — which
// matches the device bars' computeStatMidpoint instead of collapsing to the
// period start and splitting into a second stack.
const periodOffset =
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
: getPeriodMidpointOffset(period);
// Only start timestamps available here, so center sub-daily bars from the
// gap between the first two entries, clamped to the nominal period so
// sparse or lone buckets stay centered on the same grid as the device bars.
const periodOffset = getPeriodMidpointOffset(
period,
sortedTimes.length >= 2
? Number(sortedTimes[1]) - Number(sortedTimes[0])
: undefined
);
sortedTimes.forEach((time) => {
const ts = Number(time);
const x = compare
@@ -515,7 +516,11 @@ export function generateEnergyDevicesDetailGraphData(
}
}
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(datasets, start, end, getSuggestedPeriod(start, end))
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
return {
@@ -12,6 +12,7 @@ import type { HomeAssistant } from "../../../../types";
import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
generateFillBuckets,
getCompareTransform,
} from "./common/energy-chart-options";
@@ -113,7 +114,11 @@ export function generateEnergyGasGraphData(
)
);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(datasets, start, end, period)
);
const yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
const chartData = datasets;
const total = processTotal(energyData.stats, gasSources);
@@ -13,6 +13,7 @@ import { getEnergyColor } from "./common/color";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
} from "./common/energy-chart-options";
@@ -117,7 +118,11 @@ export function generateEnergySolarGraphData(
)
);
fillDataGapsAndRoundCaps(datasets as BarSeriesOption[]);
fillDataGapsAndRoundCaps(
datasets as BarSeriesOption[],
true,
generateFillBuckets(datasets as BarSeriesOption[], start, end, period)
);
if (forecasts) {
datasets.push(
@@ -322,20 +327,18 @@ function processForecast(
if (forecastsData) {
const solarForecastData: LineSeriesOption["data"] = [];
// Only center forecast points for sub-daily periods to align with bars.
// Only start timestamps available, so estimate midpoint from the gap
// between the first two entries; with a lone first bucket there is no
// gap to measure, so fall back to the nominal period midpoint.
let forecastOffset = 0;
if (period === "hour" || period === "5minute") {
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
forecastOffset =
forecastTimes.length >= 2
? (forecastTimes[1] - forecastTimes[0]) / 2
: getPeriodMidpointOffset(period);
}
// Center forecast points for sub-daily periods from the gap between
// the first two entries, clamped to the nominal period so sparse or
// lone forecast buckets still align with the bars.
const forecastTimes = Object.keys(forecastsData)
.map(Number)
.sort((a, b) => a - b);
const forecastOffset = getPeriodMidpointOffset(
period,
forecastTimes.length >= 2
? forecastTimes[1] - forecastTimes[0]
: undefined
);
for (const [time, value] of Object.entries(forecastsData)) {
const kWh = value / 1000;
solarForecastData.push([Number(time) + forecastOffset, kWh]);
@@ -37,6 +37,7 @@ import { hasConfigChanged } from "../../common/has-changed";
import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCommonOptions,
getCompareTransform,
getPeriodMidpointOffset,
@@ -450,7 +451,16 @@ export class HuiEnergyUsageGraphCard
// @ts-expect-error
datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(
datasets,
this._start,
this._end,
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._legendData = this._getLegendData(datasets);
@@ -596,15 +606,14 @@ export class HuiEnergyUsageGraphCard
const uniqueKeys = summedData.timestamps;
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
// available here, so estimate midpoint from the gap between the first two
// entries; with a lone first-of-day bucket there is no gap to measure, so
// fall back to the nominal period midpoint so the bar stays centered.
// Only start timestamps available here, so center sub-daily bars from the
// gap between the first two entries, clamped to the nominal period so
// sparse or lone buckets stay centered on the same grid as dense data.
const period = getSuggestedPeriod(this._start, this._end);
const periodOffset =
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
? (uniqueKeys[1] - uniqueKeys[0]) / 2
: getPeriodMidpointOffset(period);
const periodOffset = getPeriodMidpointOffset(
period,
uniqueKeys.length >= 2 ? uniqueKeys[1] - uniqueKeys[0] : undefined
);
const compareTransform = getCompareTransform(
this._start,
@@ -31,6 +31,7 @@ import {
computeStatMidpoint,
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
generateFillBuckets,
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
@@ -263,7 +264,16 @@ export class HuiEnergyWaterGraphCard
)
);
fillDataGapsAndRoundCaps(datasets);
fillDataGapsAndRoundCaps(
datasets,
true,
generateFillBuckets(
datasets,
this._start,
this._end,
getSuggestedPeriod(this._start, this._end)
)
);
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._total = this._processTotal(energyData.stats, waterSources);
@@ -25,8 +25,8 @@ exports[`generateEnergyGasGraphData > large 5-minute payload digest is stable (c
"unit",
"yAxisFractionDigits",
],
"numberCount": 312488,
"numberSum": "2.26308627397e+17",
"numberCount": 392840,
"numberSum": "2.71986228397e+17",
"type": "object",
}
`;
@@ -52,6 +52,15 @@ exports[`generateEnergyGasGraphData > matches snapshot for a single gas source (
"color": "#1b7ea07F",
"cursor": "default",
"data": [
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -3376,6 +3385,438 @@ exports[`generateEnergyGasGraphData > matches snapshot with compare data 1`] = `
0.287,
1704063600000,
],
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.gas_consumption_0",
"itemStyle": {
@@ -4110,6 +4551,438 @@ exports[`generateEnergyGasGraphData > matches snapshot with compare data 1`] = `
1704063600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.gas_consumption_1",
"itemStyle": {
@@ -11,8 +11,8 @@ exports[`generateEnergySolarGraphData > large 5-minute payload digest is stable
"total",
"yAxisFractionDigits",
],
"numberCount": 232066,
"numberSum": "1.50908786888e+17",
"numberCount": 285622,
"numberSum": "1.81353699874e+17",
"type": "object",
}
`;
@@ -1008,6 +1008,15 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
1705190400000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1705276800000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -1263,6 +1272,15 @@ exports[`generateEnergySolarGraphData > matches snapshot for the daily period 1`
1706745600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1706832000000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -1639,6 +1657,438 @@ exports[`generateEnergySolarGraphData > matches snapshot for two solar sources w
0.287,
1704063600000,
],
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.solar_production",
"itemStyle": {
@@ -2373,6 +2823,438 @@ exports[`generateEnergySolarGraphData > matches snapshot for two solar sources w
1704063600000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704069000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704072600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704076200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704079800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704083400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704087000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704094200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704097800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704101400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704105000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704108600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704112200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704115800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704119400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704123000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704126600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704130200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704133800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704137400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704141000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704144600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704148200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704151800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704155400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704159000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704162600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704166200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704169800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704173400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704177000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704180600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704184200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704187800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704191400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704195000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704198600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704202200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704205800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704209400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704213000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704216600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704220200000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704223800000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704227400000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704231000000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704234600000,
0,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704238200000,
0,
],
},
],
"id": "compare-sensor.solar_production_1",
"itemStyle": {
@@ -5594,6 +6476,15 @@ exports[`generateEnergySolarGraphData > matches snapshot with hourly forecast da
1704085200000,
],
},
{
"itemStyle": {
"borderWidth": 0,
},
"value": [
1704090600000,
0,
],
},
{
"itemStyle": {
"borderRadius": [
@@ -5,7 +5,9 @@ import {
computeStatMidpoint,
fillDataGapsAndRoundCaps,
fillLineGaps,
generateFillBuckets,
getCompareTransform,
getPeriodMidpointOffset,
getSuggestedMax,
splitUntrackedConsumption,
} from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
@@ -498,6 +500,307 @@ describe("fillDataGapsAndRoundCaps", () => {
assert.equal(datasets[0].data!.length, 0);
});
it("does not fill trailing buckets without an explicit grid", () => {
// Legacy behavior pin: buckets past a dataset's last real point are
// only appended when extraBuckets is passed.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
{
type: "bar",
stack: "a",
data: [
[1000, 100],
[2000, 200],
],
},
];
fillDataGapsAndRoundCaps(datasets);
assert.equal(datasets[0].data!.length, 1);
assert.equal(datasets[1].data!.length, 2);
});
it("appends trailing zero buckets from the explicit grid", () => {
// The single-reading case: one real point in the first bucket must
// be padded with zero buckets across the whole grid, otherwise ECharts
// derives a degenerate bar band width and expands the time axis.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000, 4000]);
assert.equal(datasets[0].data!.length, 4);
assert.equal(getBarItem(datasets[0], 0).value[1], 10);
for (const index of [1, 2, 3]) {
const item = getBarItem(datasets[0], index);
assert.equal(item.value[0], 1000 * (index + 1));
assert.equal(item.value[1], 0);
assert.equal(item.itemStyle.borderWidth, 0);
}
});
it("fills leading and middle buckets from the explicit grid", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[3000, 30]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000, 4000]);
assert.equal(datasets[0].data!.length, 4);
assert.deepEqual(
datasets[0].data!.map((item) => getX(item)),
[1000, 2000, 3000, 4000]
);
assert.deepEqual(
datasets[0].data!.map((item) => getY(item)),
[0, 0, 30, 0]
);
});
it("keeps originally-empty datasets empty when a grid is passed", () => {
// Compare placeholder datasets must stay empty so no-data detection
// keeps working.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [],
},
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000]);
assert.equal(datasets[0].data!.length, 0);
assert.equal(datasets[1].data!.length, 2);
});
it("still rounds caps on the real bar when grid buckets are added", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[2000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets, true, [1000, 2000, 3000]);
const realItem = getBarItem(datasets[0], 1);
assert.equal(realItem.value[1], 10);
assert.deepEqual(realItem.itemStyle.borderRadius, [4, 4, 0, 0]);
// Zero fills get no border at all
assert.equal(getBarItem(datasets[0], 0).itemStyle.borderWidth, 0);
assert.equal(getBarItem(datasets[0], 2).itemStyle.borderWidth, 0);
});
});
describe("getPeriodMidpointOffset", () => {
const HOUR = 60 * 60 * 1000;
it("returns half the nominal period when no gap was measured", () => {
assert.equal(getPeriodMidpointOffset("hour"), HOUR / 2);
assert.equal(getPeriodMidpointOffset("5minute"), 2.5 * 60 * 1000);
});
it("returns 0 for daily and longer periods", () => {
assert.equal(getPeriodMidpointOffset("day"), 0);
assert.equal(getPeriodMidpointOffset("week"), 0);
assert.equal(getPeriodMidpointOffset("month"), 0);
// Even with a measured gap
assert.equal(getPeriodMidpointOffset("day", 24 * HOUR), 0);
});
it("uses half the measured gap for finer-grained data", () => {
// e.g. 5-minute data shown with an hourly period
assert.equal(
getPeriodMidpointOffset("hour", 5 * 60 * 1000),
2.5 * 60 * 1000
);
});
it("clamps the measured gap to the nominal period for sparse data", () => {
// e.g. two readings 12h apart in an hourly view
assert.equal(getPeriodMidpointOffset("hour", 12 * HOUR), HOUR / 2);
});
});
describe("generateFillBuckets", () => {
const HOUR = 60 * 60 * 1000;
// Tests run in TZ=Etc/UTC, so local time equals UTC here.
const start = new Date("2024-03-15T00:00:00.000Z");
const end = new Date("2024-03-16T00:00:00.000Z");
const barsAt = (xs: number[], id = "main"): BarSeriesOption => ({
type: "bar",
id,
data: xs.map((x) => [x, 1, x - HOUR / 2]),
});
it("expands a single hourly bucket to the full day grid", () => {
// Real bucket: 09:00-10:00 centered at 09:30
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets([barsAt([anchor])], start, end, "hour");
assert.equal(buckets.length, 24);
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime() + 0.5 * HOUR);
assert.equal(sorted[23], start.getTime() + 23.5 * HOUR);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], HOUR);
}
assert.include(buckets, anchor);
});
it("keeps the grid anchored on data not aligned to the range start", () => {
// Half-hour timezone simulation: recorder buckets sit at :30 local, so
// midpoints are on the whole hour. The grid must follow the data, not
// the local-midnight range start.
const anchor = start.getTime() + 10 * HOUR; // 09:30-10:30 bucket
const buckets = generateFillBuckets([barsAt([anchor])], start, end, "hour");
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime());
assert.equal(sorted[sorted.length - 1], start.getTime() + 23 * HOUR);
assert.isTrue(sorted.every((ts) => (ts - anchor) % HOUR === 0));
});
it("generates 5minute buckets", () => {
const fiveMin = 5 * 60 * 1000;
const anchor = start.getTime() + fiveMin / 2;
const shortEnd = new Date(start.getTime() + HOUR);
const buckets = generateFillBuckets(
[barsAt([anchor])],
start,
shortEnd,
"5minute"
);
assert.equal(buckets.length, 12);
const sorted = [...buckets].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], fiveMin);
}
});
it("generates day buckets at period starts", () => {
const weekEnd = new Date("2024-03-22T00:00:00.000Z");
const anchor = start.getTime() + 3 * 24 * HOUR; // day 4 of the range
const buckets = generateFillBuckets(
[barsAt([anchor])],
start,
weekEnd,
"day"
);
assert.equal(buckets.length, 7);
const sorted = [...buckets].sort((a, b) => a - b);
assert.equal(sorted[0], start.getTime());
assert.equal(sorted[6], start.getTime() + 6 * 24 * HOUR);
});
it("generates month buckets with variable month lengths", () => {
const yearStart = new Date("2024-01-01T00:00:00.000Z");
const yearEnd = new Date("2025-01-01T00:00:00.000Z");
const anchor = Date.UTC(2024, 4, 1); // May 1st
const buckets = generateFillBuckets(
[barsAt([anchor])],
yearStart,
yearEnd,
"month"
);
assert.equal(buckets.length, 12);
const sorted = [...buckets].sort((a, b) => a - b);
for (let month = 0; month < 12; month++) {
assert.equal(sorted[month], Date.UTC(2024, month, 1));
}
});
it("ignores compare series and placeholders when picking the anchor", () => {
const compareAnchor = start.getTime() + 0.25 * HOUR; // off-grid transform
const mainAnchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[
{ type: "bar", id: "compare-placeholder", data: [] },
barsAt([compareAnchor], "compare-sensor.water"),
barsAt([mainAnchor], "sensor.water"),
],
start,
end,
"hour"
);
assert.include(buckets, mainAnchor);
assert.isTrue(
buckets.every((ts) => (ts - mainAnchor) % HOUR === 0),
"grid must be anchored on the main series"
);
});
it("skips empty main series and anchors on the next one with data", () => {
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[barsAt([], "sensor.empty"), barsAt([anchor], "sensor.water")],
start,
end,
"hour"
);
assert.equal(buckets.length, 24);
assert.include(buckets, anchor);
});
it("returns an empty grid when there is no data to anchor on", () => {
assert.deepEqual(generateFillBuckets([], start, end, "hour"), []);
assert.deepEqual(
generateFillBuckets(
[barsAt([], "sensor.empty"), barsAt([1000], "compare-sensor.water")],
start,
end,
"hour"
),
[]
);
});
it("reads the anchor from object-format data items", () => {
const anchor = start.getTime() + 9.5 * HOUR;
const buckets = generateFillBuckets(
[
{
type: "bar",
id: "main",
data: [{ value: [anchor, 1] }],
},
],
start,
end,
"hour"
);
assert.equal(buckets.length, 24);
assert.include(buckets, anchor);
});
});
describe("getCompareTransform", () => {
@@ -4,6 +4,7 @@
* optimization pass — see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import type { BarSeriesOption } from "echarts/charts";
import { generateEnergyGasGraphData } from "../../../../../src/panels/lovelace/cards/energy/energy-gas-graph-data";
import type { EnergyPreferences } from "../../../../../src/data/energy";
import type { HomeAssistant } from "../../../../../src/types";
@@ -209,4 +210,156 @@ describe("generateEnergyGasGraphData", () => {
)
).toMatchSnapshot();
});
// Regression tests for #52938: sparse statistics (e.g. a meter that reports
// once per day) must be zero-filled across the whole range, otherwise
// ECharts derives the bar band width from the data gaps — a lone bucket
// makes it expand the time axis by ±40% of its span and draw an oversized
// bar.
describe("sparse data zero-fill", () => {
const HOUR = 60 * 60 * 1000;
const keepBuckets = (
energyData: ReturnType<typeof generateEnergyData>,
hourOffsets: number[]
) => {
const startMs = energyData.start.getTime();
const keep = new Set(hourOffsets.map((h) => startMs + h * HOUR));
return {
...energyData,
stats: Object.fromEntries(
Object.entries(energyData.stats).map(([id, rows]) => [
id,
rows.filter((row) => keep.has(row.start)),
])
),
};
};
const getX = (item: any): number => Number(item?.value?.[0] ?? item?.[0]);
const getY = (item: any): number => Number(item?.value?.[1] ?? item?.[1]);
it("fills the full day grid around a single mid-day bucket", () => {
const energyData = keepBuckets(
generateEnergyData(8, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[10]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
const nonZero = main.data!.filter((item) => getY(item) !== 0);
expect(nonZero).toHaveLength(1);
// The real bar stays centered on its bucket midpoint.
expect(getX(nonZero[0])).toBe(energyData.start.getTime() + 10.5 * HOUR);
// The compare placeholder stays empty (no-data detection).
const placeholder = result.chartData.find((dataset) =>
String(dataset.id).startsWith("compare-")
)!;
expect(placeholder.data).toHaveLength(0);
});
it("fills the gaps between sparse readings", () => {
const energyData = keepBuckets(
generateEnergyData(9, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[2, 14]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
expect(main.data!.filter((item) => getY(item) !== 0)).toHaveLength(2);
});
it("keeps datasets empty when there is no data at all", () => {
const energyData = keepBuckets(
generateEnergyData(10, {
days: 1,
period: "hour",
prefs: gasOnlyPrefs(1),
}),
[]
);
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
for (const dataset of result.chartData) {
expect(dataset.data).toHaveLength(0);
}
});
it("propagates the grid to compare datasets", () => {
const dayMs = 24 * HOUR;
const base = generateEnergyData(11, {
days: 1,
period: "hour",
compare: true,
prefs: gasOnlyPrefs(1),
});
const energyData = {
...keepBuckets(base, [10]),
// The fixture doesn't set the compare range; provide it so compare
// rows are day-shifted onto the main axis like in the real dashboard.
startCompare: new Date(base.start.getTime() - dayMs),
endCompare: new Date(base.start.getTime()),
};
const result = generateEnergyGasGraphData({
hass: makeHass(),
energyData,
computedStyles,
now,
});
const compare = result.chartData.find(
(dataset) => dataset.id === "compare-sensor.gas_consumption_0"
)!;
// Compare data is dense here, but it must be aligned to the same
// 24-bucket grid as the zero-filled main series.
assertDenseGrid(compare.data!, 24, HOUR);
const main = result.chartData.find(
(dataset) => dataset.id === "sensor.gas_consumption_0"
)!;
assertDenseGrid(main.data!, 24, HOUR);
});
function assertDenseGrid(
data: NonNullable<BarSeriesOption["data"]>,
length: number,
gap: number
) {
expect(data).toHaveLength(length);
const xs = data.map((item) => getX(item));
expect(new Set(xs).size).toBe(length);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i] - sorted[i - 1]).toBe(gap);
}
}
});
});
@@ -164,27 +164,26 @@ describe("generateEnergyDevicesDetailGraphData", () => {
).toMatchSnapshot();
});
// Regression test for #52937: at the start of the day only the first hour
// has data. The untracked/over-reported bars must center on the same period
// midpoint as the device bars so they stack as one bar instead of splitting
// into a second stack at the period start.
it("stacks untracked bars on the device bars for a lone first-of-day bucket", () => {
// Full-day range (so getSuggestedPeriod stays "hour") but keep only the
// first hourly bucket in every stat. gapChance: 0 makes the bucket dense.
const full = generateEnergyData(1, {
// Regression test for #52937/#52938: at the start of the day only the first
// hour has data. The untracked bars must center on the same period midpoint
// as the device bars (one stack, not two), and the whole day must be
// zero-filled so ECharts keeps the configured axis range instead of
// expanding it around the lone bucket.
it("keeps a lone first-of-day bucket on the shared zero-filled grid", () => {
const HOUR = 60 * 60 * 1000;
const full = generateEnergyData(12, {
days: 1,
period: "hour",
gapChance: 0,
prefs: buildPrefs(false),
});
const firstStart = full.start.getTime();
const energyData = {
...full,
stats: Object.fromEntries(
Object.entries(full.stats).map(
([id, values]) =>
[id, values.filter((s) => s.start === firstStart)] as const
)
Object.entries(full.stats).map(([id, rows]) => [
id,
rows.filter((row) => row.start === firstStart),
])
),
};
@@ -193,26 +192,29 @@ describe("generateEnergyDevicesDetailGraphData", () => {
energyData,
});
// Collect the display x of every bar across all series.
const xs = new Set<number>();
let nonEmptySeries = 0;
const nonZeroXs = new Set<number>();
for (const series of result.chartData) {
const points = series.data ?? [];
if (points.length) {
nonEmptySeries++;
if (!series.data?.length) {
continue;
}
for (const point of points as any[]) {
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
if (x != null) {
xs.add(Number(x));
// Every non-empty series covers the full day grid...
const xs = series.data.map((item: any) =>
Number(item?.value?.[0] ?? item?.[0])
);
assert.equal(xs.length, 24);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i] - sorted[i - 1], HOUR);
}
for (const [index, item] of (series.data as any[]).entries()) {
const y = Number(item?.value?.[1] ?? item?.[1]);
if (y !== 0) {
nonZeroXs.add(xs[index]);
}
}
}
// Device bars + at least one untracked series are present...
assert.isAtLeast(nonEmptySeries, 2);
// ...and they all share a single x, so they render as one full stack.
assert.equal(xs.size, 1);
// ...and all real values stack on the single bucket midpoint.
assert.deepEqual([...nonZeroXs], [firstStart + HOUR / 2]);
});
// The seeded fixtures above all happen to produce fully-negative untracked
@@ -209,4 +209,57 @@ describe("generateEnergySolarGraphData", () => {
)
).toMatchSnapshot();
});
// Regression test for #52938: a lone statistics bucket must be zero-filled
// across the day so ECharts keeps the configured axis range, while forecast
// line series stay untouched by the bar-bucket fill.
it("zero-fills bars around a single bucket without touching forecast lines", () => {
const HOUR = 60 * 60 * 1000;
const base = generateEnergyData(7, {
days: 1,
period: "hour",
prefs: solarPrefs({ sources: 1, forecast: true }),
});
const startMs = base.start.getTime();
const energyData = {
...base,
stats: Object.fromEntries(
Object.entries(base.stats).map(([id, rows]) => [
id,
rows.filter((row) => row.start === startMs + 10 * HOUR),
])
),
};
const forecasts = buildForecasts(24, HOUR, ["entry_0"]);
const result = generateEnergySolarGraphData({
hass,
energyData,
forecasts,
computedStyles,
now,
});
const bars = result.chartData.find(
(d) => d.id === "sensor.solar_production"
)!;
const xs = bars.data!.map((item: any) =>
Number(item?.value?.[0] ?? item?.[0])
);
expect(xs).toHaveLength(24);
const sorted = [...xs].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i] - sorted[i - 1]).toBe(HOUR);
}
const forecast = result.chartData.find((d) =>
String(d.id).startsWith("forecast-")
)!;
expect(forecast.data).toHaveLength(24);
// Forecast points are centered with the nominal half-period offset.
for (const [index, item] of (forecast.data as any[]).entries()) {
expect(Number(item?.value?.[0] ?? item?.[0])).toBe(
startMs + index * HOUR + HOUR / 2
);
}
});
});