From 41c169ee9a7f9eee8bb81d9aaf8662f01078b771 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Wed, 20 May 2026 12:46:41 +0200 Subject: [PATCH] History: fix axis, scale and units (#30042) --- assets/js/components/History/GroupChart.vue | 118 ++++++++-------- assets/js/views/History.vue | 16 +-- tests/energy-history.spec.ts | 144 +++++++++++++++++++- tests/energy-history.sql | 122 +++++++++++++++-- 4 files changed, 308 insertions(+), 92 deletions(-) diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 5c56293d9..b0cbc3098 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -1,5 +1,5 @@ @@ -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 = 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(); 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; diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index c9b72a825..3af7ea278 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -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 diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts index 2d484e834..a6bfbe6a8 100644 --- a/tests/energy-history.spec.ts +++ b/tests/energy-history.spec.ts @@ -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 { + 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 { + 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 { + 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"]); + }); +}); diff --git a/tests/energy-history.sql b/tests/energy-history.sql index 9c0de65d9..9b8f897e5 100644 --- a/tests/energy-history.sql +++ b/tests/energy-history.sql @@ -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);