History: fix axis, scale and units (#30042)

This commit is contained in:
Michael Geers 2026-05-20 12:46:41 +02:00 • committed by GitHub
parent 29a932ce21
commit 41c169ee9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 308 additions and 92 deletions

View file

@ -1,5 +1,5 @@
<template>
<div class="history-chart-wrapper">
<div class="history-chart-wrapper" :data-testid="`group-chart-${group}`">
<div ref="chartEl" class="history-chart"></div>
</div>
</template>
@ -58,6 +58,18 @@ export function alphaColor(color: string, alpha: number): string {
return c;
}
// Symmetric axis regardless of whether the period contains both directions.
const BIDIRECTIONAL_GROUPS: ReadonlySet<string> = new Set(["grid", "battery"]);
// Round up to a nice number (5-tick symmetric axis: -L, -L/2, 0, L/2, L).
function niceCeil(v: number): number {
if (v <= 0) return 0;
const mag = Math.pow(10, Math.floor(Math.log10(v)));
const r = v / mag;
const n = r <= 1 ? 1 : r <= 2 ? 2 : r <= 3 ? 3 : r <= 4 ? 4 : r <= 6 ? 6 : r <= 8 ? 8 : 10;
return n * mag;
}
export default defineComponent({
name: "GroupChart",
mixins: [formatter],
@ -97,9 +109,22 @@ export default defineComponent({
valueFactor(): number {
return this.period === PERIODS.DAY ? 4 : 1;
},
// Peak per-slot stacked value in the display unit (kW for day, kWh otherwise).
// Raw peak in display unit. Bidirectional: max of |either direction|.
// Unidirectional: max of stacked per-slot sum.
axisPeak(): number {
const factor = this.valueFactor;
let max = 0;
if (this.isBidirectional) {
for (const s of this.visibleSeries) {
for (const slot of s.data) {
const a = Math.abs(slot.energy) * factor;
const b = Math.abs(slot.returnEnergy) * factor;
if (a > max) max = a;
if (b > max) max = b;
}
}
return max;
}
const slotSums = new Map<string, number>();
for (const s of this.visibleSeries) {
for (const slot of s.data) {
@ -107,25 +132,26 @@ export default defineComponent({
slotSums.set(slot.start, (slotSums.get(slot.start) || 0) + v);
}
}
let max = 0;
for (const v of slotSums.values()) if (v > max) max = v;
return max;
},
maxVisibleKw(): number {
return this.period === PERIODS.DAY ? this.axisPeak : Infinity;
// W/Wh scale when peak below 1 kW(h). Zero data falls here too.
useSmallUnit(): boolean {
return this.axisPeak < 1;
},
// Switch to watts when the peak is below 1 kW so small values stay readable.
useWatts(): boolean {
return this.period === PERIODS.DAY && this.maxVisibleKw < 1;
// Rounded range. W mode floors at 1 kW (= 1000 W) for stable context.
axisLimit(): number {
const v = niceCeil(this.axisPeak);
return this.useSmallUnit ? Math.max(v, 1) : v;
},
// 1 decimal when echarts may pick 0.5-step splits, else 0 — avoids "1, 1, 2, 2".
// 1-3 kW(h) band gets one decimal to avoid duplicate integer ticks.
axisDigits(): number {
if (this.isBidirectional || this.useWatts) return 0;
return this.axisPeak < 3 ? 1 : 0;
if (this.useSmallUnit) return 0;
return this.axisLimit > 0 && this.axisLimit <= 3 ? 1 : 0;
},
unit(): "W" | "kW" | "kWh" {
if (this.period !== PERIODS.DAY) return "kWh";
return this.useWatts ? "W" : "kW";
unit(): "W" | "Wh" | "kW" | "kWh" {
if (this.period === PERIODS.DAY) return this.useSmallUnit ? "W" : "kW";
return this.useSmallUnit ? "Wh" : "kWh";
},
// Consumer groups have many palette colours per entity — a single neutral
// tooltip background reads better than picking one entity's colour.
@ -141,40 +167,7 @@ export default defineComponent({
return this.series.filter((s, i) => (s.paletteIndex ?? i) === idx);
},
isBidirectional(): boolean {
for (const s of this.visibleSeries) {
let energy = 0;
let returnEnergy = 0;
for (const slot of s.data) {
energy += slot.energy;
returnEnergy += slot.returnEnergy;
}
if (energy > 0 && returnEnergy > 0) return true;
}
return false;
},
// Soft symmetric limit. Rounded up to an even-friendly nice number so
// splitNumber: 4 gives integer ticks ±X, ±X/2, 0.
bidirectionalLimit(): number {
let m = 0;
const factor = this.valueFactor;
for (const s of this.visibleSeries) {
for (const slot of s.data) {
const a = Math.abs(slot.energy * factor);
const b = Math.abs(slot.returnEnergy * factor);
if (a > m) m = a;
if (b > m) m = b;
}
}
if (m <= 0) return 2;
const mag = Math.pow(10, Math.floor(Math.log10(m)));
const r = m / mag;
let n;
if (r <= 2) n = 2;
else if (r <= 4) n = 4;
else if (r <= 6) n = 6;
else if (r <= 8) n = 8;
else n = 10;
return n * mag;
return BIDIRECTIONAL_GROUPS.has(this.group);
},
categoryTimestamps(): number[] {
const out: number[] = [];
@ -576,13 +569,15 @@ export default defineComponent({
},
},
yAxis: forecastYAxis({
...(this.isBidirectional
...(this.isBidirectional && this.axisLimit > 0
? {
min: -this.bidirectionalLimit,
max: this.bidirectionalLimit,
interval: this.bidirectionalLimit / 2,
min: -this.axisLimit,
max: this.axisLimit,
interval: this.axisLimit / 2,
}
: {}),
: this.useSmallUnit
? { max: this.axisLimit, interval: this.axisLimit / 4 }
: {}),
position: "right",
splitNumber: 3,
splitLine: {
@ -607,15 +602,12 @@ export default defineComponent({
axisLabel: {
color: colors.muted || "",
hideOverlap: true,
formatter: (v: number) =>
this.period === PERIODS.DAY
? this.fmtW(
v * 1000,
this.useWatts ? POWER_UNIT.W : POWER_UNIT.KW,
false,
this.axisDigits
)
: this.fmtWh(v * 1000, POWER_UNIT.KW, false, this.axisDigits),
formatter: (v: number): string => {
const unit = this.useSmallUnit ? POWER_UNIT.W : POWER_UNIT.KW;
return this.period === PERIODS.DAY
? this.fmtW(v * 1000, unit, false, this.axisDigits)
: this.fmtWh(v * 1000, unit, false, this.axisDigits);
},
},
}),
series: this.echartsSeries,
@ -647,7 +639,7 @@ export default defineComponent({
yAxis: opt["yAxis"],
series: opt["series"],
},
fullReset ? { notMerge: true } : { replaceMerge: ["series"] }
fullReset ? { notMerge: true } : { replaceMerge: ["series", "yAxis"] }
);
this.previousFocusedEntity = this.focusedEntity as number | null;
this.previousPeriod = this.period as PERIODS;

View file

@ -284,23 +284,13 @@ export default defineComponent({
},
visibleGroups(): string[] {
return GROUP_ORDER.filter((g) => {
// Consumption uses `home` as the source of truth, so the section
// shows up whenever home has data, even without explicit meters.
// Consumption section follows `home` (the source of truth).
if (g === "meter") {
const home = this.seriesByGroup["home"];
if (
home?.some((s) =>
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
)
) {
return true;
}
if (home?.some((s) => s.data.length > 0)) return true;
}
const list = this.seriesByGroup[g];
if (!list?.length) return false;
return list.some((s) =>
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
);
return !!list?.some((s) => s.data.length > 0);
});
},
// Series shown in the chart. For the consumption (`meter`) group we append

View file

@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page, type Locator } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
@ -13,7 +13,7 @@ test.afterAll(async () => {
await stop();
});
test.describe("energy history API", () => {
test.describe("api", () => {
test("15-minute resolution", async ({ request }) => {
const res = await request.get(`${baseUrl()}/api/history/energy`, {
params: { from, to },
@ -23,8 +23,8 @@ test.describe("energy history API", () => {
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: any) => s.name === "grid");
const home = data.find((s: any) => s.name === "home");
const grid = data.find((s: { name: string }) => s.name === "grid");
const home = data.find((s: { name: string }) => s.name === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
@ -46,8 +46,8 @@ test.describe("energy history API", () => {
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: any) => s.name === "grid");
const home = data.find((s: any) => s.name === "home");
const grid = data.find((s: { name: string }) => s.name === "grid");
const home = data.find((s: { name: string }) => s.name === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
@ -70,3 +70,135 @@ test.describe("energy history API", () => {
expect(gridDay2.returnEnergy).toBeCloseTo(0.2, 4);
});
});
async function gotoDay(page: Page, year: number, month: number, day: number): Promise<void> {
await page.goto(`/#/history?period=day&year=${year}&month=${month}&day=${day}`);
await expect(page.getByRole("heading", { name: /history/i }).first()).toBeVisible();
}
async function gotoMonth(page: Page, year: number, month: number): Promise<void> {
await page.goto(`/#/history?period=month&year=${year}&month=${month}`);
await expect(page.getByRole("heading", { name: /history/i }).first()).toBeVisible();
}
function chart(page: Page, group: string): Locator {
return page.getByTestId(`group-chart-${group}`);
}
function section(page: Page, group: string): Locator {
return page.getByTestId(`history-section-${group}`);
}
// Y-axis labels rendered by echarts: position=right uses text-anchor=start.
// DOM order: axis name first, then ticks bottom→top (min, ..., max).
async function yAxis(c: Locator): Promise<string[]> {
const els = c.locator('svg text[text-anchor="start"]');
await els.first().waitFor();
return els.allTextContents();
}
test.describe("axis and units", () => {
test("grid ±2 kW bidirectional, 1 decimal", async ({ page }) => {
await gotoDay(page, 2026, 3, 24);
expect(await yAxis(chart(page, "grid"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
});
test("sub-1 kW switches to W, floored at 1000 W", async ({ page }) => {
await gotoDay(page, 2026, 4, 1);
expect(await yAxis(chart(page, "grid"))).toEqual(["W", "-1,000", "-500", "0", "500", "1,000"]);
});
test("import-only stays symmetric", async ({ page }) => {
await gotoDay(page, 2026, 4, 2);
expect(await yAxis(chart(page, "grid"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
});
test("battery ±3 kW, 1 decimal", async ({ page }) => {
await gotoDay(page, 2026, 4, 3);
expect(await yAxis(chart(page, "battery"))).toEqual([
"kW",
"-3.0",
"-1.5",
"0.0",
"1.5",
"3.0",
]);
});
test("battery ±6 kW, integer labels", async ({ page }) => {
await gotoDay(page, 2026, 4, 4);
expect(await yAxis(chart(page, "battery"))).toEqual(["kW", "-6", "-3", "0", "3", "6"]);
});
test("unidirectional axis stays positive", async ({ page }) => {
await gotoDay(page, 2026, 4, 5);
// Unidirectional groups use echarts auto-scale (min: 0). Peak 1.6 kW → ticks 0..2.
expect(await yAxis(chart(page, "pv"))).toEqual(["kW", "0.0", "0.5", "1.0", "1.5", "2.0"]);
});
test("all-zero data: W axis at 1000 W", async ({ page }) => {
await gotoDay(page, 2026, 4, 6);
await expect(section(page, "pv")).toBeVisible();
expect(await yAxis(chart(page, "pv"))).toEqual(["W", "0", "250", "500", "750", "1,000"]);
});
test("stacked entities + overlay not clipped", async ({ page }) => {
await gotoDay(page, 2026, 5, 2);
// Stacked east+west peak = 1.8 kWh × 4 = 7.2 kW → niceCeil = 8.
expect(await yAxis(chart(page, "pv"))).toEqual(["kW", "0", "2", "4", "6", "8"]);
});
test("month view uses kWh unit", async ({ page }) => {
await gotoMonth(page, 2026, 6);
expect(await yAxis(chart(page, "battery"))).toEqual([
"kWh",
"-2.0",
"-1.0",
"0.0",
"1.0",
"2.0",
]);
});
});
test.describe("consumption breakdown", () => {
// 2026-04-07: home = 1.0 kWh, Kitchen = 0.4 kWh, Office = 0.3 kWh,
// virtual Others = home − meters = 0.3 kWh.
test("home total, meter legend, virtual Others", async ({ page }) => {
await gotoDay(page, 2026, 4, 7);
const consumption = section(page, "meter");
await expect(consumption).toBeVisible();
// Section header total comes from `home`, not the sum of meters.
await expect(consumption.getByRole("heading")).toContainText("1.0 kWh");
// Entity legend lists Others (virtual) + explicit meters.
const legend = consumption.getByRole("button");
await expect(legend).toHaveCount(3);
await expect(consumption).toContainText("Others");
await expect(consumption).toContainText("Kitchen");
await expect(consumption).toContainText("Office");
// Per-entity totals via fmtWh + POWER_UNIT.AUTO.
await expect(consumption).toContainText("400 Wh"); // Kitchen
await expect(consumption).toContainText("300 Wh"); // Office + Others both 0.3 kWh
});
test("entity focus rescales axis and resets on unfocus", async ({ page }) => {
await gotoDay(page, 2026, 4, 7);
const consumption = section(page, "meter");
const meterChart = chart(page, "meter");
// Stacked total per slot 1.0 kW → echarts auto-axis 0..1.2 in 1-decimal.
expect(await yAxis(meterChart)).toEqual(["kW", "0.0", "0.3", "0.6", "0.9", "1.2"]);
// Focus Kitchen → peak 400 W → unit switches to W, floored at 1000 W.
const kitchen = consumption.getByRole("button", { name: "Kitchen 400 Wh" });
await kitchen.click();
await expect.poll(() => yAxis(meterChart)).toEqual(["W", "0", "250", "500", "750", "1,000"]);
// Unfocus → axis returns to kW range, not stuck on 1000 W cap.
await kitchen.click();
await expect.poll(() => yAxis(meterChart)).toEqual(["kW", "0.0", "0.3", "0.6", "0.9", "1.2"]);
});
});

View file

@ -1,6 +1,3 @@
DROP TABLE IF EXISTS `meters`;
DROP TABLE IF EXISTS `entities`;
CREATE TABLE `entities` (
`id` integer,
`group` text,
@ -18,14 +15,22 @@ CREATE TABLE `meters` (
CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`);
-- entities
INSERT INTO `entities` VALUES (1, 'virtual', 'home');
INSERT INTO `entities` VALUES (1, 'home', 'home');
INSERT INTO `entities` VALUES (2, 'grid', 'grid');
INSERT INTO `entities` VALUES (3, 'battery', 'battery');
INSERT INTO `entities` VALUES (4, 'pv', 'solar');
INSERT INTO `entities` VALUES (5, 'meter', 'Kitchen');
INSERT INTO `entities` VALUES (6, 'meter', 'Office');
INSERT INTO `entities` VALUES (7, 'pv', 'pv-east');
INSERT INTO `entities` VALUES (8, 'pv', 'pv-west');
INSERT INTO `entities` VALUES (9, 'forecast', 'forecast');
-- meter data: 6 slots per entity spanning midnight (local time +01:00)
-- 2026-03-24: 22:00, 22:15, 22:30, 22:45 (4 slots)
-- 2026-03-25: 00:00, 00:15 (2 slots)
-- home (id=1): import=0.1, export=0 per slot
-- =====================================================================
-- API test data: 2026-03-24/25 (existing). Used by the JSON-shape tests.
-- 2026-03-24 22:00, 22:15, 22:30, 22:45 CET (+01:00) → 4 slots
-- 2026-03-25 00:00, 00:15 CET → 2 slots
-- =====================================================================
-- home: import=0.1, export=0 per slot
INSERT INTO `meters` VALUES (1, 1774386000, 0.1, 0);
INSERT INTO `meters` VALUES (1, 1774386900, 0.1, 0);
INSERT INTO `meters` VALUES (1, 1774387800, 0.1, 0);
@ -33,10 +38,107 @@ INSERT INTO `meters` VALUES (1, 1774388700, 0.1, 0);
INSERT INTO `meters` VALUES (1, 1774393200, 0.1, 0);
INSERT INTO `meters` VALUES (1, 1774394100, 0.1, 0);
-- grid (id=2): import=0.5, export=0.1 per slot
-- grid: import=0.5, export=0.1 per slot (peak 2 kW bidirectional)
INSERT INTO `meters` VALUES (2, 1774386000, 0.5, 0.1);
INSERT INTO `meters` VALUES (2, 1774386900, 0.5, 0.1);
INSERT INTO `meters` VALUES (2, 1774387800, 0.5, 0.1);
INSERT INTO `meters` VALUES (2, 1774388700, 0.5, 0.1);
INSERT INTO `meters` VALUES (2, 1774393200, 0.5, 0.1);
INSERT INTO `meters` VALUES (2, 1774394100, 0.5, 0.1);
-- =====================================================================
-- UI / chart test data: one local Berlin day per case, 12:00-12:45 CEST.
-- All slots are 15 minutes; energy column is kWh in DB.
-- =====================================================================
-- 2026-04-01 → grid sub-1-kW range. peak 4 W → unit W, symmetric axis.
-- 0.001 kWh × 4 = 0.004 kW = 4 W. Stored values must survive Wh-rounding
-- in the backend (db_history.roundEnergy), so we use kWh values ≥ 0.001.
INSERT INTO `meters` VALUES (2, 1775037600, 0.001, 0.001);
INSERT INTO `meters` VALUES (2, 1775038500, 0.001, 0.001);
INSERT INTO `meters` VALUES (2, 1775039400, 0.001, 0.001);
INSERT INTO `meters` VALUES (2, 1775040300, 0.001, 0.001);
-- 2026-04-02 → grid import-only. peak 2 kW. Axis must still be symmetric
-- because grid is in the hardcoded bidirectional list.
INSERT INTO `meters` VALUES (2, 1775124000, 0.5, 0);
INSERT INTO `meters` VALUES (2, 1775124900, 0.5, 0);
INSERT INTO `meters` VALUES (2, 1775125800, 0.5, 0);
INSERT INTO `meters` VALUES (2, 1775126700, 0.5, 0);
-- 2026-04-03 → battery 2.4 kW peak. niceCeil(2.4) = 3.
-- Alternating slots: charge / discharge / charge / discharge.
INSERT INTO `meters` VALUES (3, 1775210400, 0.6, 0);
INSERT INTO `meters` VALUES (3, 1775211300, 0, 0.5);
INSERT INTO `meters` VALUES (3, 1775212200, 0.6, 0);
INSERT INTO `meters` VALUES (3, 1775213100, 0, 0.5);
-- 2026-04-04 → battery 4.8 kW peak. niceCeil(4.8) = 6 → integer kW labels.
INSERT INTO `meters` VALUES (3, 1775296800, 1.2, 0);
INSERT INTO `meters` VALUES (3, 1775297700, 0, 1.0);
INSERT INTO `meters` VALUES (3, 1775298600, 1.2, 0);
INSERT INTO `meters` VALUES (3, 1775299500, 0, 1.0);
-- 2026-04-05 → PV unidirectional. peak 1.6 kW.
INSERT INTO `meters` VALUES (4, 1775383200, 0.4, 0);
INSERT INTO `meters` VALUES (4, 1775384100, 0.4, 0);
INSERT INTO `meters` VALUES (4, 1775385000, 0.4, 0);
INSERT INTO `meters` VALUES (4, 1775385900, 0.4, 0);
-- 2026-04-06 → PV all-zero day (cloudy). Rows exist but values are 0.
-- Regression: chart group must still render.
INSERT INTO `meters` VALUES (4, 1775469600, 0, 0);
INSERT INTO `meters` VALUES (4, 1775470500, 0, 0);
INSERT INTO `meters` VALUES (4, 1775471400, 0, 0);
INSERT INTO `meters` VALUES (4, 1775472300, 0, 0);
-- 2026-04-07 → consumption breakdown. home = 1.0 kWh total; explicit
-- meters Kitchen 0.4 / Office 0.3; virtual "Others" = home − meters = 0.3.
INSERT INTO `meters` VALUES (1, 1775556000, 0.25, 0);
INSERT INTO `meters` VALUES (1, 1775556900, 0.25, 0);
INSERT INTO `meters` VALUES (1, 1775557800, 0.25, 0);
INSERT INTO `meters` VALUES (1, 1775558700, 0.25, 0);
INSERT INTO `meters` VALUES (5, 1775556000, 0.1, 0);
INSERT INTO `meters` VALUES (5, 1775556900, 0.1, 0);
INSERT INTO `meters` VALUES (5, 1775557800, 0.1, 0);
INSERT INTO `meters` VALUES (5, 1775558700, 0.1, 0);
INSERT INTO `meters` VALUES (6, 1775556000, 0.075, 0);
INSERT INTO `meters` VALUES (6, 1775556900, 0.075, 0);
INSERT INTO `meters` VALUES (6, 1775557800, 0.075, 0);
INSERT INTO `meters` VALUES (6, 1775558700, 0.075, 0);
-- 2026-05-02 → multi-entity PV with stacked peak 7.2 kW. Two PV entities
-- (east + west) plus a forecast overlay. axisPeak comes from the stacked
-- per-slot sum, niceCeil → 8 → integer kW labels, echarts auto-range.
INSERT INTO `meters` VALUES (7, 1777687200, 0.05, 0); -- 04:00
INSERT INTO `meters` VALUES (7, 1777694400, 0.30, 0); -- 06:00
INSERT INTO `meters` VALUES (7, 1777701600, 0.60, 0); -- 08:00
INSERT INTO `meters` VALUES (7, 1777708800, 0.80, 0); -- 10:00
INSERT INTO `meters` VALUES (7, 1777716000, 1.00, 0); -- 12:00
INSERT INTO `meters` VALUES (7, 1777723200, 0.70, 0); -- 14:00
INSERT INTO `meters` VALUES (7, 1777730400, 0.30, 0); -- 16:00
INSERT INTO `meters` VALUES (7, 1777737600, 0.05, 0); -- 18:00
INSERT INTO `meters` VALUES (8, 1777687200, 0, 0);
INSERT INTO `meters` VALUES (8, 1777694400, 0.10, 0);
INSERT INTO `meters` VALUES (8, 1777701600, 0.40, 0);
INSERT INTO `meters` VALUES (8, 1777708800, 0.60, 0);
INSERT INTO `meters` VALUES (8, 1777716000, 0.80, 0);
INSERT INTO `meters` VALUES (8, 1777723200, 1.00, 0);
INSERT INTO `meters` VALUES (8, 1777730400, 0.80, 0);
INSERT INTO `meters` VALUES (8, 1777737600, 0.30, 0);
INSERT INTO `meters` VALUES (9, 1777687200, 0.05, 0);
INSERT INTO `meters` VALUES (9, 1777694400, 0.40, 0);
INSERT INTO `meters` VALUES (9, 1777701600, 1.00, 0);
INSERT INTO `meters` VALUES (9, 1777708800, 1.40, 0);
INSERT INTO `meters` VALUES (9, 1777716000, 1.80, 0);
INSERT INTO `meters` VALUES (9, 1777723200, 1.70, 0);
INSERT INTO `meters` VALUES (9, 1777730400, 1.10, 0);
INSERT INTO `meters` VALUES (9, 1777737600, 0.35, 0);
-- =====================================================================
-- Month-aggregation case: 2026-06, three days with battery bidirectional
-- data. Daily totals ~1.4 / 1.0 kWh → limit rounds to 2 → 1-decimal kWh.
-- =====================================================================
INSERT INTO `meters` VALUES (3, 1781517600, 1.4, 1.0);
INSERT INTO `meters` VALUES (3, 1781604000, 1.4, 1.0);
INSERT INTO `meters` VALUES (3, 1781690400, 1.4, 1.0);