From ff20a359f86d1a47fb29b1c9be40f8e318858a91 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 3 Oct 2026 14:41:49 +0200 Subject: [PATCH 01/29] Metrics: do not rebase energy baseline on a lower meter total (#34218) --- core/metrics/accumulator.go | 71 +++++++++++++++++--------------- core/metrics/accumulator_test.go | 23 +++++++++++ core/metrics/collector.go | 10 ++--- core/metrics/collector_test.go | 6 +-- 4 files changed, 68 insertions(+), 42 deletions(-) diff --git a/core/metrics/accumulator.go b/core/metrics/accumulator.go index 228bc0190c..75e25b9cbc 100644 --- a/core/metrics/accumulator.go +++ b/core/metrics/accumulator.go @@ -11,13 +11,36 @@ import ( type Accumulator struct { clock clock.Clock updated time.Time - energyMeter *float64 // kWh - returnEnergyMeter *float64 // kWh + energyMeter meterTotal + returnEnergyMeter meterTotal Energy float64 `json:"energy"` // kWh ReturnEnergy float64 `json:"returnEnergy"` // kWh SocTemp *float64 `json:"socTemp,omitempty"` } +// meterTotal tracks a cumulative meter reading in kWh. A lower reading is ignored +// as a dropout until a second, increasing lower reading confirms the counter reset. +type meterTotal struct { + last, reset *float64 +} + +// add returns the increase over the last reading and advances it +func (t *meterTotal) add(v float64) float64 { + switch { + case t.last == nil: + t.last = &v + case v >= *t.last: + delta := v - *t.last + t.last, t.reset = &v, nil + return delta + case t.reset != nil && v > *t.reset: + t.last, t.reset = &v, nil + default: + t.reset = &v + } + return 0 +} + // AccumulatorState is the resumable meter-reading checkpoint of an Accumulator. type AccumulatorState struct { EnergyMeter *float64 // kWh, last absolute reading @@ -26,13 +49,13 @@ type AccumulatorState struct { // Snapshot returns the current meter readings for persistence. func (m *Accumulator) Snapshot() AccumulatorState { - return AccumulatorState{EnergyMeter: m.energyMeter, ReturnEnergyMeter: m.returnEnergyMeter} + return AccumulatorState{EnergyMeter: m.energyMeter.last, ReturnEnergyMeter: m.returnEnergyMeter.last} } // Restore seeds the meter readings so the first delta covers the downtime. func (m *Accumulator) Restore(s AccumulatorState) { - m.energyMeter = s.EnergyMeter - m.returnEnergyMeter = s.ReturnEnergyMeter + m.energyMeter = meterTotal{last: s.EnergyMeter} + m.returnEnergyMeter = meterTotal{last: s.ReturnEnergyMeter} } // CompleteFor reports whether the state can seed a collector of the given group. @@ -68,13 +91,13 @@ func NewAccumulator(opt ...func(*Accumulator)) *Accumulator { func (m *Accumulator) String() string { b := new(bytes.Buffer) fmt.Fprintf(b, "Accumulated: %.3fkWh energy, %.3fkWh return energy, updated: %v", m.Energy, m.ReturnEnergy, m.updated.Truncate(time.Second)) - if m.energyMeter != nil || m.returnEnergyMeter != nil { + if m.energyMeter.last != nil || m.returnEnergyMeter.last != nil { fmt.Fprintf(b, " energy total:") - if m.energyMeter != nil { - fmt.Fprintf(b, " %.3fkWh", *m.energyMeter) + if m.energyMeter.last != nil { + fmt.Fprintf(b, " %.3fkWh", *m.energyMeter.last) } - if m.returnEnergyMeter != nil { - fmt.Fprintf(b, " %.3fkWh return energy", *m.returnEnergyMeter) + if m.returnEnergyMeter.last != nil { + fmt.Fprintf(b, " %.3fkWh return energy", *m.returnEnergyMeter.last) } } return b.String() @@ -82,34 +105,14 @@ func (m *Accumulator) String() string { // SetEnergyMeterTotal adds the difference to the last total meter value in kWh func (m *Accumulator) SetEnergyMeterTotal(v float64) { - defer func() { - m.updated = m.clock.Now() - m.energyMeter = new(v) - }() - - if m.energyMeter == nil { - return - } - - if v >= *m.energyMeter { - m.Energy += v - *m.energyMeter - } + m.updated = m.clock.Now() + m.Energy += m.energyMeter.add(v) } // SetReturnEnergyMeterTotal adds the difference to the last total meter value in kWh func (m *Accumulator) SetReturnEnergyMeterTotal(v float64) { - defer func() { - m.updated = m.clock.Now() - m.returnEnergyMeter = new(v) - }() - - if m.returnEnergyMeter == nil { - return - } - - if v >= *m.returnEnergyMeter { - m.ReturnEnergy += v - *m.returnEnergyMeter - } + m.updated = m.clock.Now() + m.ReturnEnergy += m.returnEnergyMeter.add(v) } // AddEnergy adds the given energy in kWh to the energy total diff --git a/core/metrics/accumulator_test.go b/core/metrics/accumulator_test.go index bd68564ada..fd9125a2fd 100644 --- a/core/metrics/accumulator_test.go +++ b/core/metrics/accumulator_test.go @@ -23,6 +23,29 @@ func TestMeterEnergyMeterTotal(t *testing.T) { assert.Equal(t, 1.0, me.Energy) } +func TestMeterEnergyMeterTotalDropout(t *testing.T) { + me := &Accumulator{clock: clock.NewMock()} + + // transient lower reading must not rebase the baseline (#33820) + me.SetEnergyMeterTotal(20658) + me.SetEnergyMeterTotal(11179) + me.SetEnergyMeterTotal(20659) + assert.Equal(t, 1.0, me.Energy) + + // a repeated lower reading that does not increase is still not accepted + me.SetEnergyMeterTotal(5) + me.SetEnergyMeterTotal(5) + me.SetEnergyMeterTotal(20660) + assert.Equal(t, 2.0, me.Energy) + + // an increasing lower reading confirms a counter reset without adding energy + me.SetEnergyMeterTotal(5) + me.SetEnergyMeterTotal(6) + assert.Equal(t, 2.0, me.Energy) + me.SetEnergyMeterTotal(8) + assert.Equal(t, 4.0, me.Energy) +} + func TestMeterEnergyAddPower(t *testing.T) { clock := clock.NewMock() clock.Set(now.BeginningOfDay()) diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 0ed99546e0..91db8aa12b 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -217,12 +217,12 @@ func (c *Collector) SetCapabilities(energy, returnEnergy bool) error { // keyed on the entity, since an incomplete state is left unrestored and would // otherwise resurface once the other direction is checkpointed again if !energy && c.entity.EnergyMeter != nil { - c.accu.energyMeter = nil + c.accu.energyMeter = meterTotal{} c.entity.EnergyMeter = nil cols["energy_meter"] = nil } if !returnEnergy && c.entity.ReturnEnergyMeter != nil { - c.accu.returnEnergyMeter = nil + c.accu.returnEnergyMeter = meterTotal{} c.entity.ReturnEnergyMeter = nil cols["return_energy_meter"] = nil } @@ -233,7 +233,7 @@ func (c *Collector) SetCapabilities(energy, returnEnergy bool) error { // a surviving reading still covers the downtime for its own direction, so // keep the restore rather than discarding that delta with the cleared one - c.restored = c.accu.energyMeter != nil || c.accu.returnEnergyMeter != nil + c.restored = c.accu.energyMeter.last != nil || c.accu.returnEnergyMeter.last != nil return db.Instance.Model(&c.entity).UpdateColumns(cols).Error } @@ -258,8 +258,8 @@ func (c *Collector) AddEnergy(energyTotal, returnEnergyTotal *float64, power flo return c.process(func() { // a direction that ever reported a total is metered, so a nil read is a // transient failure rather than a power-only meter - hasEnergyMeter := energyTotal != nil || c.accu.energyMeter != nil - hasReturnMeter := returnEnergyTotal != nil || c.accu.returnEnergyMeter != nil + hasEnergyMeter := energyTotal != nil || c.accu.energyMeter.last != nil + hasReturnMeter := returnEnergyTotal != nil || c.accu.returnEnergyMeter.last != nil // integrate power for the unmetered direction first, since applying a // meter total advances the accumulator clock diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 26a94239d4..980dc4601b 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -628,7 +628,7 @@ func TestCollectorFallsBackToPowerAfterCapabilityLoss(t *testing.T) { require.True(t, col2.restored) require.NoError(t, col2.SetCapabilities(false, false)) - require.Nil(t, col2.accu.energyMeter) + require.Nil(t, col2.accu.energyMeter.last) require.False(t, col2.restored, "no readings left to seed a restore") require.NoError(t, db.Instance.First(&e, col2.entity.Id).Error) @@ -661,7 +661,7 @@ func TestCollectorClearsUnrestoredCheckpoint(t *testing.T) { col2, err := NewCollector(Grid, "incomplete", "", WithClock(clk)) require.NoError(t, err) require.False(t, col2.restored, "incomplete state must not restore") - require.Nil(t, col2.accu.energyMeter) + require.Nil(t, col2.accu.energyMeter.last) require.NoError(t, col2.SetCapabilities(false, false)) @@ -695,7 +695,7 @@ func TestCollectorKeepsRestoreForSurvivingDirection(t *testing.T) { require.NoError(t, err) require.NoError(t, col2.SetCapabilities(true, false)) require.True(t, col2.restored, "the surviving import reading still seeds a restore") - require.Nil(t, col2.accu.returnEnergyMeter) + require.Nil(t, col2.accu.returnEnergyMeter.last) // the import delta across the downtime is kept require.NoError(t, col2.AddEnergy(new(111.0), nil, 0)) From 3f62e6c0c7c64bcddb907e86d2ad158c2d99a964 Mon Sep 17 00:00:00 2001 From: Jeremiah <4462211+jeremiahpslewis@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:08:59 +0200 Subject: [PATCH 02/29] Fixes: Optimize page is slow with multi-day horizons (#34096) Co-authored-by: Claude Co-authored-by: Michael Geers --- assets/js/components/Optimize/ChargeChart.vue | 10 +++++++--- assets/js/components/Optimize/SocChart.vue | 5 ++--- assets/js/components/Optimize/TimeSeriesDataTable.vue | 4 ++++ assets/js/components/Optimize/chart.ts | 8 +++++++- assets/js/mixins/echartsChart.ts | 8 +++++++- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 236fada20b..1478f1bb01 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -11,7 +11,6 @@ import { axisNameStyle, FONT_FAMILY, forecastYAxis, - hoverDot, lineCasing, tooltipStyle, tooltipTable, @@ -35,6 +34,7 @@ import { whToKW, loadpointTitle, demandTitle, + transientHoverDot, } from "./chart"; const GRID_LABEL = "Grid Power"; @@ -131,7 +131,7 @@ export default defineComponent({ z: 4, data: this.gridPower, smooth: 0.2, - ...hoverDot(colors.grid || ""), + ...transientHoverDot(colors.grid || ""), lineStyle: { color: colors.grid || "", ...lineDefaults }, }; const solar = { @@ -140,7 +140,7 @@ export default defineComponent({ z: 4, data: this.evopt.req.time_series.ft.map(this.toKW), smooth: 0.2, - ...hoverDot(colors.forecast || ""), + ...transientHoverDot(colors.forecast || ""), lineStyle: { color: colors.forecast || "", ...lineDefaults }, }; const series: Record[] = [ @@ -153,6 +153,10 @@ export default defineComponent({ name: e.label, type: "bar", stack: "charge", + // one path per series instead of an svg element per slot. Stacking works in + // large mode despite the outdated TODO in echarts' barGrid layout + large: true, + largeThreshold: 0, data: e.data, itemStyle: { color: e.color }, emphasis: { disabled: true }, diff --git a/assets/js/components/Optimize/SocChart.vue b/assets/js/components/Optimize/SocChart.vue index 0115906fd2..d4e8bd1b87 100644 --- a/assets/js/components/Optimize/SocChart.vue +++ b/assets/js/components/Optimize/SocChart.vue @@ -15,7 +15,6 @@ import { forecastYAxis, tooltipStyle, tooltipTable, - hoverDot, lineDefaults, } from "../Forecast/echarts"; import colors, { dimColor } from "@/colors"; @@ -24,7 +23,7 @@ import echartsChart from "@/mixins/echartsChart"; import { is12hFormat } from "@/units"; import type { EvoptData } from "./TimeSeriesDataTable.vue"; import LegendList from "../Sessions/LegendList.vue"; -import { slotTimes } from "./chart"; +import { slotTimes, transientHoverDot } from "./chart"; export interface SocChartEntry { index: number; // index into evopt.res.batteries @@ -120,7 +119,7 @@ export default defineComponent({ step: "start", z: 3, data: this.socSeries, - ...hoverDot(this.entry.color), + ...transientHoverDot(this.entry.color), lineStyle: { color: this.entry.color, ...lineDefaults }, areaStyle: { color: dimColor(this.entry.color) }, }, diff --git a/assets/js/components/Optimize/TimeSeriesDataTable.vue b/assets/js/components/Optimize/TimeSeriesDataTable.vue index 148866c77a..5dca1d46b3 100644 --- a/assets/js/components/Optimize/TimeSeriesDataTable.vue +++ b/assets/js/components/Optimize/TimeSeriesDataTable.vue @@ -308,6 +308,10 @@ export default defineComponent({ diff --git a/assets/js/components/Energy/ConsumerTreemap.vue b/assets/js/components/Energy/ConsumerTreemap.vue new file mode 100644 index 0000000000..92f982991c --- /dev/null +++ b/assets/js/components/Energy/ConsumerTreemap.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/assets/js/components/Energy/CostStats.vue b/assets/js/components/Energy/CostStats.vue new file mode 100644 index 0000000000..746aaf1c1b --- /dev/null +++ b/assets/js/components/Energy/CostStats.vue @@ -0,0 +1,105 @@ + + + diff --git a/assets/js/components/Energy/EntityDetail.vue b/assets/js/components/Energy/EntityDetail.vue new file mode 100644 index 0000000000..37587c6fe3 --- /dev/null +++ b/assets/js/components/Energy/EntityDetail.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/assets/js/components/Energy/FlowChart.vue b/assets/js/components/Energy/FlowChart.vue new file mode 100644 index 0000000000..5462c9a53b --- /dev/null +++ b/assets/js/components/Energy/FlowChart.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/assets/js/components/Energy/ForecastDeviation.vue b/assets/js/components/Energy/ForecastDeviation.vue new file mode 100644 index 0000000000..5ee6ee314e --- /dev/null +++ b/assets/js/components/Energy/ForecastDeviation.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/assets/js/components/Energy/ForecastStat.stories.ts b/assets/js/components/Energy/ForecastStat.stories.ts new file mode 100644 index 0000000000..c03d197551 --- /dev/null +++ b/assets/js/components/Energy/ForecastStat.stories.ts @@ -0,0 +1,101 @@ +import StatCards from "./StatCards.vue"; +import Stat from "./Stat.vue"; +import ForecastDeviation from "./ForecastDeviation.vue"; +import formatter from "@/mixins/formatter"; +import { groupColor } from "./groups"; +import type { Meta, StoryFn } from "@storybook/vue3"; + +export default { + title: "Energy/ForecastStat", + component: StatCards, +} as Meta; + +interface Args { + remaining?: number; + tomorrow?: number; + accuracy?: number; + delta?: number; + spans?: ([number, number] | null)[]; +} + +// 15 minute buckets of a sunny day, production off by `factor` around midday +const day = (factor: number): ([number, number] | null)[] => + Array.from({ length: 96 }, (_, i) => { + const sun = Math.max(0, Math.sin(((i - 24) / 56) * Math.PI)); + const forecast = sun * 2; + return [forecast, forecast * (1 + factor * Math.sin(i / 3))]; + }); + +const labels = Array.from({ length: 96 }, (_, i) => { + const h = String(Math.floor(i / 4)).padStart(2, "0"); + const m = String((i % 4) * 15).padStart(2, "0"); + return `${h}:${m}`; +}); + +const Template: StoryFn = (args) => ({ + components: { StatCards, Stat, ForecastDeviation }, + mixins: [formatter], + setup() { + return { args, labels, color: groupColor("forecast") }; + }, + computed: { + stats() { + const kWh = (v: number) => this.fmtKWh(v); + if (args.remaining !== undefined) { + return [ + { + key: "remaining", + label: this.$t("energy.group.forecast"), + number: args.remaining, + format: kWh, + sub: this.$t("energy.production.remainingToday"), + }, + ]; + } + const delta = args.delta ?? 0; + return [ + { + key: "forecast", + label: this.$t("energy.production.accuracy"), + number: args.accuracy, + format: (v: number) => this.fmtPercentage(v), + sub: this.$t(delta >= 0 ? "energy.production.above" : "energy.production.below", { + value: this.fmtKWh(Math.abs(delta)), + }), + }, + ]; + }, + }, + template: ` + +
+
+ + + + +
+
+ `, +}); + +export const Today = Template.bind({}); +Today.args = { remaining: 22, tomorrow: 48 }; + +export const TodayWithoutTomorrow = Template.bind({}); +TodayWithoutTomorrow.args = { remaining: 22 }; + +export const AccuracyAbove = Template.bind({}); +AccuracyAbove.args = { accuracy: 87, delta: 3.2, spans: day(-0.15) }; + +export const AccuracyBelow = Template.bind({}); +AccuracyBelow.args = { accuracy: 72, delta: -5.4, spans: day(0.3) }; diff --git a/assets/js/components/Energy/GridStats.vue b/assets/js/components/Energy/GridStats.vue new file mode 100644 index 0000000000..ace0c10d15 --- /dev/null +++ b/assets/js/components/Energy/GridStats.vue @@ -0,0 +1,53 @@ + + + diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/Energy/GroupChart.vue similarity index 54% rename from assets/js/components/History/GroupChart.vue rename to assets/js/components/Energy/GroupChart.vue index bdce9ffd61..233846e48c 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/Energy/GroupChart.vue @@ -1,12 +1,13 @@ diff --git a/assets/js/components/Energy/MixBar.vue b/assets/js/components/Energy/MixBar.vue new file mode 100644 index 0000000000..5b2e5067fe --- /dev/null +++ b/assets/js/components/Energy/MixBar.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/assets/js/components/Energy/PatternChart.vue b/assets/js/components/Energy/PatternChart.vue new file mode 100644 index 0000000000..eda42ffe70 --- /dev/null +++ b/assets/js/components/Energy/PatternChart.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/assets/js/components/Energy/SourcesModal.vue b/assets/js/components/Energy/SourcesModal.vue new file mode 100644 index 0000000000..0c619a6cf6 --- /dev/null +++ b/assets/js/components/Energy/SourcesModal.vue @@ -0,0 +1,119 @@ + + + diff --git a/assets/js/components/Energy/Stat.vue b/assets/js/components/Energy/Stat.vue new file mode 100644 index 0000000000..d73e1e9e5a --- /dev/null +++ b/assets/js/components/Energy/Stat.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/assets/js/components/Energy/StatCards.vue b/assets/js/components/Energy/StatCards.vue new file mode 100644 index 0000000000..c56352da2e --- /dev/null +++ b/assets/js/components/Energy/StatCards.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/assets/js/components/Energy/echarts.ts b/assets/js/components/Energy/echarts.ts new file mode 100644 index 0000000000..aa66580723 --- /dev/null +++ b/assets/js/components/Energy/echarts.ts @@ -0,0 +1,14 @@ +import { echarts } from "../Forecast/echarts"; +import { SankeyChart, HeatmapChart, ThemeRiverChart } from "echarts/charts"; +import { CalendarComponent, SingleAxisComponent, VisualMapComponent } from "echarts/components"; + +echarts.use([ + SankeyChart, + HeatmapChart, + ThemeRiverChart, + CalendarComponent, + SingleAxisComponent, + VisualMapComponent, +]); + +export * from "../Forecast/echarts"; diff --git a/assets/js/components/History/groups.ts b/assets/js/components/Energy/groups.ts similarity index 100% rename from assets/js/components/History/groups.ts rename to assets/js/components/Energy/groups.ts diff --git a/assets/js/components/Energy/mix.test.ts b/assets/js/components/Energy/mix.test.ts new file mode 100644 index 0000000000..c6668d8dfd --- /dev/null +++ b/assets/js/components/Energy/mix.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vite-plus/test"; +import { sinkCost, sourceBreakdown } from "./mix"; + +describe("sourceBreakdown", () => { + test("scales the sink's split and prices to the entity", () => { + const rows = sourceBreakdown( + [ + { from: "pv", to: "loadpoint", energy: 6, cost: 0.3, pricedEnergy: 3 }, + { from: "grid", to: "loadpoint", energy: 2, cost: 0.6, pricedEnergy: 2 }, + { from: "grid", to: "home", energy: 9 }, + ], + "loadpoint", + 4, + ["pv", "grid"] + ); + expect(rows).toEqual([ + { from: "pv", share: 75, energy: 3, price: expect.closeTo(0.1), cost: expect.closeTo(0.3) }, + { from: "grid", share: 25, energy: 1, price: expect.closeTo(0.3), cost: expect.closeTo(0.3) }, + ]); + }); + + test("no cost without priced energy, only the given sources", () => { + const rows = sourceBreakdown([{ from: "pv", to: "home", energy: 1 }], "home", 1, [ + "pv", + "battery", + ]); + expect(rows).toEqual([ + { from: "pv", share: 100, energy: 1, price: undefined, cost: undefined }, + { from: "battery", share: 0, energy: 0, price: undefined, cost: undefined }, + ]); + }); +}); + +describe("sinkCost", () => { + test("sums cost and priced energy of the flows into the sinks", () => { + const flows = [ + { from: "pv", to: "home", energy: 2, cost: 0.2, pricedEnergy: 2 }, + { from: "grid", to: "loadpoint", energy: 3, cost: 0.6, pricedEnergy: 2 }, + { from: "pv", to: "export", energy: 5 }, + ] as const; + expect(sinkCost([...flows], "home")).toEqual({ cost: 0.2, energy: 2 }); + expect(sinkCost([...flows], "home", "loadpoint")).toEqual({ + cost: expect.closeTo(0.8), + energy: 4, + }); + expect(sinkCost([...flows], "export")).toEqual({ cost: 0, energy: 0 }); + }); +}); diff --git a/assets/js/components/Energy/mix.ts b/assets/js/components/Energy/mix.ts new file mode 100644 index 0000000000..d377d0836c --- /dev/null +++ b/assets/js/components/Energy/mix.ts @@ -0,0 +1,50 @@ +import type { Flow, FlowSink, FlowSource } from "./types"; + +export function sumEnergy( + series: { data: { energy: number; returnEnergy: number }[] }, + key: "energy" | "returnEnergy" = "energy" +): number { + return series.data.reduce((acc, slot) => acc + slot[key], 0); +} + +// what the energy into the given sinks cost, and how much of it was priced +export function sinkCost(flows: Flow[], ...sinks: FlowSink[]): { cost: number; energy: number } { + const into = flows.filter((f) => sinks.includes(f.to)); + return { + cost: into.reduce((acc, f) => acc + (f.cost ?? 0), 0), + energy: into.reduce((acc, f) => acc + (f.pricedEnergy ?? 0), 0), + }; +} + +export interface SourceRow { + from: FlowSource; + share: number; // percent of what reached the sink + energy: number; // kWh + cost?: number; // undefined without price data + price?: number; // per kWh +} + +// what reached the sink from each of `sources` over the period, scaled to `energy`: +// an entity of the sink (one consumer, one loadpoint) inherits the sink's split and prices +export function sourceBreakdown( + flows: Flow[], + sink: FlowSink, + energy: number, + sources: FlowSource[] +): SourceRow[] { + const into = flows.filter((f) => f.to === sink); + const total = into.reduce((acc, f) => acc + f.energy, 0); + if (!total) return []; + return sources.map((from) => { + const flow = into.find((f) => f.from === from); + const share = (flow?.energy ?? 0) / total; + const price = flow?.pricedEnergy ? (flow.cost ?? 0) / flow.pricedEnergy : undefined; + return { + from, + share: share * 100, + energy: share * energy, + price, + cost: price === undefined ? undefined : price * share * energy, + }; + }); +} diff --git a/assets/js/components/Energy/slots.ts b/assets/js/components/Energy/slots.ts new file mode 100644 index 0000000000..8fdf0100bf --- /dev/null +++ b/assets/js/components/Energy/slots.ts @@ -0,0 +1,5 @@ +// the metrics are recorded per 15 minute slot +export const SLOT_MS = 15 * 60 * 1000; + +// average power in W of a slot's energy in kWh +export const slotWatts = (kWh: number) => (kWh * 1000 * 36e5) / SLOT_MS; diff --git a/assets/js/components/Energy/subPanel.ts b/assets/js/components/Energy/subPanel.ts new file mode 100644 index 0000000000..ae3a7b3550 --- /dev/null +++ b/assets/js/components/Energy/subPanel.ts @@ -0,0 +1,78 @@ +import colors, { setAlpha } from "@/colors"; +import { forecastYAxis, hoverDot, lineDefaults } from "./echarts"; + +// skinny panel below a chart's main plot, sharing its x axis +const PANEL_HEIGHT = 40; +const PANEL_GAP = 24; +export const PANEL_EXTRA = 48; // added chart height, the main plot gives up the rest + +export interface SubPanel { + series: Record[]; + yAxis: Record; + track: string; // background of the panel, the full range +} + +// soc or temperature per category as line and area on grid, x and y axis 1 +export function socTempPanel( + values: (number | null)[], + color: string, + isTemp: boolean, + format: (v: number) => string +): SubPanel { + // soc spans 0 to 100, temperature the data's range widened to full tens + let [min, max] = [0, 100]; + if (isTemp) { + const present = values.filter((v): v is number => v !== null); + min = Math.floor(Math.min(...present) / 10) * 10; + max = Math.max(Math.ceil(Math.max(...present) / 10) * 10, min + 10); + } + // a light box needs less tint than a dark one + const dark = document.documentElement.classList.contains("dark"); + return { + // same hue as the area, so the fill stays a clean tint of the device color + track: setAlpha(color, dark ? "1a" : "0d") || "", + series: [ + { + id: "soctemp", + name: "soctemp", + type: "line", + xAxisIndex: 1, + yAxisIndex: 1, + data: values, + smooth: true, + ...hoverDot(color), + connectNulls: true, + lineStyle: { color, ...lineDefaults }, + areaStyle: { color: setAlpha(color, "40") }, + z: 4, + }, + ], + yAxis: forecastYAxis({ + gridIndex: 1, + position: "left", + min, + max, + // labels at both ends only + interval: max - min, + // the grid's background is the track, a second set of lines would fight the main grid + splitLine: { show: false }, + axisLabel: { color: colors.muted || "", hideOverlap: true, formatter: format }, + }), + }; +} + +// the main plot's grid shortened, and the panel's grid below it +export function panelGrids(main: { bottom: number; left: number; right: number }, track: string) { + return [ + { ...main, bottom: main.bottom + PANEL_HEIGHT + PANEL_GAP }, + { + bottom: main.bottom, + height: PANEL_HEIGHT, + left: main.left, + right: main.right, + borderWidth: 0, + show: true, + backgroundColor: track, + }, + ]; +} diff --git a/assets/js/components/Energy/treemap.ts b/assets/js/components/Energy/treemap.ts new file mode 100644 index 0000000000..09ea027dd0 --- /dev/null +++ b/assets/js/components/Energy/treemap.ts @@ -0,0 +1,69 @@ +export interface TreemapRect { + x: number; + y: number; + width: number; + height: number; +} + +// Squarified treemap (Bruls et al.). Values must be sorted descending. +// `aspect` stretches the target shape: 2 prefers tiles twice as wide as high. +export function squarify( + values: number[], + width: number, + height: number, + aspect = 1 +): TreemapRect[] { + const total = values.reduce((a, b) => a + b, 0); + const out: TreemapRect[] = []; + if (!total || width <= 0 || height <= 0) + return values.map(() => ({ x: 0, y: 0, width: 0, height: 0 })); + + // lay out in a space where the preferred tile is square, scale back at the end + const w = width / aspect; + const scale = (w * height) / total; + let x = 0; + let y = 0; + let free = { w, h: height }; + let row: number[] = []; + + const worst = (r: number[], side: number) => { + const sum = r.reduce((a, b) => a + b, 0) * scale; + const max = Math.max(...r) * scale; + const min = Math.min(...r) * scale; + const s2 = side * side; + return Math.max((s2 * max) / (sum * sum), (sum * sum) / (s2 * min)); + }; + + const flush = () => { + const sum = row.reduce((a, b) => a + b, 0) * scale; + const horizontal = free.w >= free.h; // row fills the shorter side + const thickness = sum / (horizontal ? free.h : free.w); + let offset = 0; + for (const v of row) { + const len = (v * scale) / thickness; + out.push( + horizontal + ? { x, y: y + offset, width: thickness, height: len } + : { x: x + offset, y, width: len, height: thickness } + ); + offset += len; + } + if (horizontal) { + x += thickness; + free = { w: free.w - thickness, h: free.h }; + } else { + y += thickness; + free = { w: free.w, h: free.h - thickness }; + } + row = []; + }; + + for (const v of values) { + const side = Math.min(free.w, free.h); + if (row.length && worst([...row, v], side) > worst(row, side)) flush(); + row.push(v); + } + if (row.length) flush(); + + return out.map((r) => ({ ...r, x: r.x * aspect, width: r.width * aspect })); +} diff --git a/assets/js/components/Energy/types.ts b/assets/js/components/Energy/types.ts new file mode 100644 index 0000000000..a846ec9145 --- /dev/null +++ b/assets/js/components/Energy/types.ts @@ -0,0 +1,92 @@ +export type FlowSource = "pv" | "battery" | "grid"; +export type FlowSink = "home" | "battery" | "loadpoint" | "export"; + +export interface Flow { + from: FlowSource; + to: FlowSink; + energy: number; // kWh + cost?: number; // home and loadpoint only + pricedEnergy?: number; // kWh with a price +} + +export interface FlowCost { + import: number; + export: number; + importEnergy: number; // kWh with a price + exportEnergy: number; // kWh with a price + avgGrid: number; +} + +export interface FlowCo2 { + consumption: number; // kg, grid share to home and loadpoints + consumptionEnergy: number; // kWh + baseline: number; // kg + avgCo2: number; // g/kWh +} + +export interface FlowResult { + flows: Flow[]; + cost?: FlowCost; + co2?: FlowCo2; +} + +export interface TariffSlot { + start: string; + grid?: number; // bucket average when aggregated + gridMin?: number; + gridMax?: number; + feedin?: number; + feedinMin?: number; + feedinMax?: number; + co2?: number; + temperature?: number; +} + +// price per chart category: bucket average with the slot range +export interface PriceBand { + avg: (number | null)[]; + lo: (number | null)[]; + hi: (number | null)[]; + cost?: (number | null)[]; // what the category's energy cost or earned +} + +export interface PriceOverlay { + import?: PriceBand; + feedin?: PriceBand; +} + +export interface ConsumerEnergy { + title: string; + energy: number; // kWh +} + +// loadpoint and consumer charts +export enum ENTITY_CHART { + BARS = "bars", + PATTERN = "pattern", +} + +// the consumers card: shares of the period, or the consumers over time +export enum CONSUMER_VIEW { + TREEMAP = "treemap", + RIVER = "river", +} + +// the overview card +export enum OVERVIEW_VIEW { + OVERVIEW = "overview", + USAGE = "usage", + FLOW = "flow", +} + +// one Stat card, a slot named after the key renders below the number +export interface StatItem { + key: string; + label?: string; + number?: number; // animated, formatted with `format` + format?: (n: number) => string; + sub?: string; + tooltip?: string; + accent?: string; // text color class for the value + empty?: string; // shown instead of a value, with a link to the tariff settings +} diff --git a/assets/js/components/Energy/usage.test.ts b/assets/js/components/Energy/usage.test.ts new file mode 100644 index 0000000000..3208e4fe69 --- /dev/null +++ b/assets/js/components/Energy/usage.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vite-plus/test"; +import { usageSplit, type UsageSlot } from "./usage"; + +const slot = (s: Partial): UsageSlot => ({ + pv: 0, + gridIn: 0, + gridOut: 0, + batOut: 0, + home: 0, + bats: [], + lps: [], + ...s, +}); + +describe("usageSplit", () => { + test("production covers export, battery, home and loadpoints", () => { + const [r] = usageSplit([slot({ pv: 2, gridOut: 0.5, bats: [0.5], home: 0.3, lps: [0.7] })]); + expect(r).toEqual({ export: 0.5, bats: [0.5], home: 0.3, lps: [0.7] }); + }); + + test("import and discharge feed what production left", () => { + const [r] = usageSplit([ + slot({ pv: 1, gridIn: 1.5, batOut: 0.5, home: 1.25, bats: [0.25], lps: [1.5] }), + ]); + expect(r).toEqual({ export: 0, bats: [0.25], home: 1.25, lps: [1.5] }); + }); + + test("hourly booked charger: import stays with consumption, the lump is capped", () => { + const rows = usageSplit([ + slot({ gridIn: 3, home: 0.1, lps: [0] }), + slot({ gridIn: 0.2, home: 0.1, lps: [6] }), + ]); + expect(rows[0]).toEqual({ export: 0, bats: [], home: 3, lps: [0] }); + expect(rows[1]!.home).toBeCloseTo(0.1); + expect(rows[1]!.lps[0]).toBeCloseTo(0.1); + }); + + test("unclaimed energy stays with consumption", () => { + const [r] = usageSplit([slot({ pv: 2, gridIn: 1, home: 0.5, lps: [0.5] })]); + expect(r).toEqual({ export: 0, bats: [], home: 2.5, lps: [0.5] }); + }); +}); diff --git a/assets/js/components/Energy/usage.ts b/assets/js/components/Energy/usage.ts new file mode 100644 index 0000000000..791069fec4 --- /dev/null +++ b/assets/js/components/Energy/usage.ts @@ -0,0 +1,55 @@ +export interface UsageSlot { + pv: number; + gridIn: number; + gridOut: number; + batOut: number; // discharge, all batteries + home: number; + bats: number[]; // charge per battery + lps: number[]; // charged per loadpoint +} + +// what each sink received in the slot +export interface UsageRow { + export: number; + bats: number[]; + home: number; + lps: number[]; +} + +// Assigns every slot's sources, production first, then battery discharge and grid +// import, to the sinks: export and battery charge from production as in the +// overview, then consumers before loadpoints like the flow attribution. Energy no +// sink claims stays with consumption so the sinks always add up to the sources; a +// sink no source covers is not drawn. Chargers that book their energy hourly show +// up this way, that is a data quality issue the chart does not paper over. +export function usageSplit(slots: UsageSlot[]): UsageRow[] { + return slots.map((s) => { + let up = s.pv; + const fromPv = (v: number) => { + const x = Math.min(up, Math.max(0, v)); + up -= x; + return x; + }; + const exportUp = fromPv(s.gridOut); + const batUp = s.bats.map(fromPv); + const homeUp = fromPv(s.home); + const lpUp = s.lps.map(fromPv); + + let down = s.gridIn + s.batOut; + const fromImport = (v: number) => { + const x = Math.min(down, Math.max(0, v)); + down -= x; + return x; + }; + const homeDown = fromImport(s.home - homeUp); + const batDown = s.bats.map((b, i) => fromImport(b - batUp[i]!)); + const lpDown = s.lps.map((l, i) => fromImport(l - lpUp[i]!)); + + return { + export: exportUp, + bats: batUp.map((u, i) => u + batDown[i]!), + home: homeUp + up + homeDown + down, + lps: lpUp.map((u, i) => u + lpDown[i]!), + }; + }); +} diff --git a/assets/js/components/Forecast/GridDetails.vue b/assets/js/components/Forecast/GridDetails.vue index c9c567d252..633de4dd64 100644 --- a/assets/js/components/Forecast/GridDetails.vue +++ b/assets/js/components/Forecast/GridDetails.vue @@ -88,11 +88,12 @@ export default defineComponent({ const avg = values.reduce((a, b) => a + b, 0) / values.length; const fmtAvg = this.fmtPricePerKWh(avg, this.currency, false, true); if (isStaticTariff(upcoming)) return { avg: fmtAvg, range: "" }; - const min = Math.min(...values); - const max = Math.max(...values); - const fmtMin = this.fmtPricePerKWh(min, this.currency, false, false); - const fmtMax = this.fmtPricePerKWh(max, this.currency, false, true); - return { avg: `⌀ ${fmtAvg}`, range: `${fmtMin} – ${fmtMax}` }; + const range = this.fmtPriceRange( + Math.min(...values), + Math.max(...values), + this.currency + ); + return { avg: `⌀ ${fmtAvg}`, range }; }, upcomingSlots(slots?: UiForecastSlot[]): UiForecastSlot[] { if (!Array.isArray(slots)) return []; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 8fc9474a20..8f01c18054 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -1,6 +1,6 @@ import * as echarts from "echarts/core"; import colors from "@/colors"; -import { attachTouchTooltipGate } from "@/utils/swipe"; +import { attachTouchTooltipGate, pointerState } from "@/utils/swipe"; import escapeHtml from "@/utils/escapeHtml"; import type { UiForecastSlot } from "@/types/evcc"; import { BarChart, LineChart } from "echarts/charts"; @@ -114,30 +114,49 @@ export function lineCasing(line: Record, z: number): Record size.viewSize[0] ? point[0] - margin - w : point[0] + margin; - return [x, point[1] - h / 2]; + const { x: px, y: py, touch } = pointerState(); + const edge = 8; + // 40px clears a fingertip, 16px keeps a cursor off the tooltip + const [x, y] = touch + ? [px - w / 2, py - 40 - h >= edge ? py - 40 - h : py + 40] + : [px + 16 + w + edge > window.innerWidth ? px - 16 - w : px + 16, py - h / 2]; + const cx = Math.min(Math.max(x, edge), window.innerWidth - w - edge); + const cy = Math.min(Math.max(y, edge), window.innerHeight - h - edge); + // the pointer sits at `point` in chart space, translate back from the viewport + return [cx - (px - point[0]), cy - (py - point[1])]; }, textStyle: { fontFamily: FONT_FAMILY, @@ -152,12 +171,17 @@ export function tooltipStyle(color: string) { export function registerTouchTooltip( chart: Pick, el: HTMLElement, - onReset?: () => void + onReset?: () => void, + onTap?: (x: number, y: number) => void ) { - attachTouchTooltipGate(el, () => { - chart.dispatchAction({ type: "hideTip" }); - onReset?.(); - }); + attachTouchTooltipGate( + el, + () => { + chart.dispatchAction({ type: "hideTip" }); + onReset?.(); + }, + onTap + ); } export interface TooltipRow { @@ -204,7 +228,10 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[ .map(rowHtml) .join("")}` : ""; - return `${headerRow}${body}${foot}
${head}
`; + const thead = head + ? `${head}` + : ""; + return `${thead}${headerRow}${body}${foot}
`; } export function forecastGrid() { diff --git a/assets/js/components/Helper/Card.vue b/assets/js/components/Helper/Card.vue index 205dd44687..1834954b27 100644 --- a/assets/js/components/Helper/Card.vue +++ b/assets/js/components/Helper/Card.vue @@ -1,55 +1,32 @@ diff --git a/assets/js/components/Helper/DeviceColorDot.vue b/assets/js/components/Helper/DeviceColorDot.vue new file mode 100644 index 0000000000..74180eb186 --- /dev/null +++ b/assets/js/components/Helper/DeviceColorDot.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/assets/js/components/MaterialIcon/BarChart.vue b/assets/js/components/MaterialIcon/BarChart.vue new file mode 100644 index 0000000000..12dffd76cb --- /dev/null +++ b/assets/js/components/MaterialIcon/BarChart.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/MaterialIcon/FullStackedBarChart.vue b/assets/js/components/MaterialIcon/FullStackedBarChart.vue new file mode 100644 index 0000000000..944c44f2ff --- /dev/null +++ b/assets/js/components/MaterialIcon/FullStackedBarChart.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/MaterialIcon/Plug.vue b/assets/js/components/MaterialIcon/Plug.vue new file mode 100644 index 0000000000..933e1b1508 --- /dev/null +++ b/assets/js/components/MaterialIcon/Plug.vue @@ -0,0 +1,29 @@ + + + diff --git a/assets/js/components/MaterialIcon/Sankey.vue b/assets/js/components/MaterialIcon/Sankey.vue new file mode 100644 index 0000000000..6201518b63 --- /dev/null +++ b/assets/js/components/MaterialIcon/Sankey.vue @@ -0,0 +1,26 @@ + + + diff --git a/assets/js/components/MaterialIcon/Stream.vue b/assets/js/components/MaterialIcon/Stream.vue new file mode 100644 index 0000000000..2bb2f16d01 --- /dev/null +++ b/assets/js/components/MaterialIcon/Stream.vue @@ -0,0 +1,25 @@ + + + diff --git a/assets/js/components/MaterialIcon/Treemap.vue b/assets/js/components/MaterialIcon/Treemap.vue new file mode 100644 index 0000000000..dddf2b895a --- /dev/null +++ b/assets/js/components/MaterialIcon/Treemap.vue @@ -0,0 +1,31 @@ + + + diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue index 1478f1bb01..0146da06ed 100644 --- a/assets/js/components/Optimize/ChargeChart.vue +++ b/assets/js/components/Optimize/ChargeChart.vue @@ -81,7 +81,7 @@ export default defineComponent({ }, computed: { consumptionLabel(): string { - return this.$t("main.history.group.consumer"); + return this.$t("energy.group.consumer"); }, consumptionColor(): string { return colors.muted || ""; diff --git a/assets/js/components/Optimize/TimeSeriesDataTable.vue b/assets/js/components/Optimize/TimeSeriesDataTable.vue index 5dca1d46b3..3ff3dc5258 100644 --- a/assets/js/components/Optimize/TimeSeriesDataTable.vue +++ b/assets/js/components/Optimize/TimeSeriesDataTable.vue @@ -200,7 +200,7 @@ export default defineComponent({ // the profiles the household demand is summarized from, scaled against the total demandRows(total: Row): Row[] { const ref = Math.max(...total.nums.map(Math.abs)); - const consumption = this.$t("main.history.group.consumer"); + const consumption = this.$t("energy.group.consumer"); return this.demandDetails.map((d, i) => this.powerRow( `↳ ${demandTitle(d, consumption)}`, diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue index 04e7ee8ee2..9adaa99ada 100644 --- a/assets/js/components/Sessions/CostHistoryChart.vue +++ b/assets/js/components/Sessions/CostHistoryChart.vue @@ -131,14 +131,10 @@ export default defineComponent({ const items = dataset.data.filter((v): v is number => v !== null); const min = Math.min(...items); const max = Math.max(...items); - const format = (value: number, withUnit: boolean) => { - return this.costType === TYPES.PRICE - ? this.fmtPricePerKWh(value, this.currency, false, withUnit) - : withUnit - ? this.fmtCo2Medium(value) - : this.fmtGrams(value, false); - }; - value = `${format(min, false)} – ${format(max, true)}`; + value = + this.costType === TYPES.PRICE + ? this.fmtPriceRange(min, max, this.currency) + : `${this.fmtGrams(min, false)} – ${this.fmtCo2Medium(max)}`; type = "line"; } else { const total = dataset.data.reduce((acc: number, curr) => acc + (curr || 0), 0); diff --git a/assets/js/components/Sessions/LegendList.vue b/assets/js/components/Sessions/LegendList.vue index 2693750f86..0d802bfbd4 100644 --- a/assets/js/components/Sessions/LegendList.vue +++ b/assets/js/components/Sessions/LegendList.vue @@ -1,16 +1,22 @@ @@ -69,81 +50,38 @@ import { defineComponent, type PropType } from "vue"; import type { Legend } from "./types"; import type { DeviceColors } from "@/types/evcc"; -import ColorPickerPopover from "../Helper/ColorPickerPopover.vue"; -import api from "../../api"; +import DeviceColorDot from "../Helper/DeviceColorDot.vue"; export default defineComponent({ name: "LegendList", - components: { ColorPickerPopover }, + components: { DeviceColorDot }, props: { legends: Array as PropType, grid: Boolean, + // dot, label and values in aligned columns, wrapping into column groups by breakpoint + columns: Boolean, smallEqualWidths: Boolean, deviceColors: { type: Object as PropType, default: () => ({}) }, }, emits: ["focus"], - data() { - return { - pickerOpen: false, - pickerAnchor: null as HTMLElement | null, - pickerId: "", - }; - }, - computed: { - pickerTitle(): string { - const found = (this.legends || []).find((l) => l.id === this.pickerId); - return found?.label || this.pickerId; - }, - pickerColor(): string { - return this.deviceColors[this.pickerId] || ""; - }, - }, methods: { valueList(value: Legend["value"]) { if (!value) return []; return Array.isArray(value) ? value : [value]; }, - colorClass(legend: Legend) { - return legend.type === "line" ? "legend-color--line" : "legend-color--area"; - }, - isPickable(legend: Legend) { - return !!legend.id; - }, - onPick(legend: Legend, e: MouseEvent) { - if (!legend.id) return; - if (this.pickerOpen && this.pickerId === legend.id) { - this.pickerOpen = false; - return; - } - this.pickerAnchor = (e.currentTarget as HTMLElement) || null; - this.pickerId = legend.id; - this.pickerOpen = true; - }, onFocus(legend: Legend) { this.$emit("focus", legend); }, - async onColorChange(color: string) { - if (!this.pickerId) return; - try { - await api.put("devicecolors", { title: this.pickerId, color }); - } catch (e) { - console.error("set device color failed", e); - } - }, }, }); diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue deleted file mode 100644 index 2f4cb64571..0000000000 --- a/assets/js/views/History.vue +++ /dev/null @@ -1,619 +0,0 @@ - - - - - diff --git a/core/metrics/db_flow.go b/core/metrics/db_flow.go new file mode 100644 index 0000000000..1f73612e6a --- /dev/null +++ b/core/metrics/db_flow.go @@ -0,0 +1,193 @@ +package metrics + +import ( + "time" + + "github.com/evcc-io/evcc/db" +) + +// flow sinks; sources reuse the group names PV, Battery, Grid +const Export = "export" + +// Flow is the energy attributed from one source to one sink over the queried period. +type Flow struct { + From string `json:"from"` + To string `json:"to"` + Energy float64 `json:"energy"` // kWh + Cost float64 `json:"cost,omitempty"` // home and loadpoint only + PricedEnergy float64 `json:"pricedEnergy,omitempty"` // kWh with a price +} + +// FlowCost sums the grid tariff over the slots that carry a grid price. What +// home and loadpoints cost is on the flows that reached them. +type FlowCost struct { + Import float64 `json:"import"` + Export float64 `json:"export"` + ImportEnergy float64 `json:"importEnergy"` // kWh with a price + ExportEnergy float64 `json:"exportEnergy"` // kWh with a price + AvgGrid float64 `json:"avgGrid"` +} + +// FlowCo2 sums the grid co2 intensity (kg) over the slots that carry a co2 +// value. Consumption counts only the grid share that went to home and loadpoints. +type FlowCo2 struct { + Consumption float64 `json:"consumption"` + ConsumptionEnergy float64 `json:"consumptionEnergy"` // kWh + Baseline float64 `json:"baseline"` + AvgCo2 float64 `json:"avgCo2"` // g/kWh +} + +// FlowResult is the source to sink energy attribution of a period. +type FlowResult struct { + Flows []Flow `json:"flows"` + Cost *FlowCost `json:"cost,omitempty"` + Co2 *FlowCo2 `json:"co2,omitempty"` +} + +// flowSlot holds one 15min slot with all groups pivoted into columns +type flowSlot struct { + PV, GridIn, GridOut, BatIn, BatOut, Home, Loadpoint float64 + Grid, FeedIn, Co2 *float64 +} + +var ( + flowSources = []string{PV, Battery, Grid} + flowSinks = []string{Home, Battery, Loadpoint, Export} +) + +// attribute distributes the slot's sources onto its sinks in priority order: +// pv before battery before grid, home before battery charge before loadpoints +// before export. Mirrors the live greenShare rule. Any balance mismatch is dropped. +func attribute(s flowSlot, add func(from, to string, energy float64)) { + src := map[string]float64{PV: s.PV, Battery: s.BatOut, Grid: s.GridIn} + dst := map[string]float64{Home: s.Home, Battery: s.BatIn, Loadpoint: s.Loadpoint, Export: s.GridOut} + + for _, from := range flowSources { + for _, to := range flowSinks { + if from == to || (from == Grid && to == Export) { + continue + } + if x := min(src[from], dst[to]); x > 0 { + add(from, to, x) + src[from] -= x + dst[to] -= x + } + } + } +} + +// QueryFlow attributes the energy of [from,to) from sources to sinks per 15min +// slot and sums the result. Zero bounds are open ended. +func QueryFlow(from, to time.Time) (FlowResult, error) { + tx := db.Instance.Table("meters m"). + Select(` + COALESCE(SUM(CASE WHEN e."group" = 'pv' THEN m.energy END), 0) AS pv, + COALESCE(SUM(CASE WHEN e."group" = 'grid' THEN m.energy END), 0) AS grid_in, + COALESCE(SUM(CASE WHEN e."group" = 'grid' THEN m.return_energy END), 0) AS grid_out, + COALESCE(SUM(CASE WHEN e."group" = 'battery' THEN m.energy END), 0) AS bat_in, + COALESCE(SUM(CASE WHEN e."group" = 'battery' THEN m.return_energy END), 0) AS bat_out, + COALESCE(SUM(CASE WHEN e."group" = 'home' THEN m.energy END), 0) AS home, + COALESCE(SUM(CASE WHEN e."group" = 'loadpoint' THEN m.energy END), 0) AS loadpoint, + t.grid, t.feedin AS feed_in, t.co2`). + Joins("JOIN entities e ON m.meter = e.id"). + Joins("LEFT JOIN tariffs t ON t.ts = m.ts"). + Where(`e."group" IN ?`, []string{PV, Grid, Battery, Home, Loadpoint}). + Group("m.ts") + + if !from.IsZero() { + tx = tx.Where("m.ts >= ?", from.Unix()) + } + if !to.IsZero() { + tx = tx.Where("m.ts < ?", to.Unix()) + } + + // stream slots to keep memory flat for long periods + rows, err := tx.Rows() + if err != nil { + return FlowResult{}, err + } + defer rows.Close() + + sums := make(map[[2]string]float64) + costs := make(map[[2]string]float64) + priced := make(map[[2]string]float64) + var cost FlowCost + var co2 FlowCo2 + var costSlots, co2Slots float64 + + for rows.Next() { + var s flowSlot + if err := db.Instance.ScanRows(rows, &s); err != nil { + return FlowResult{}, err + } + + // energy that reached home and loadpoints, and the grid's part of it. + // Consumption a slot's sources cannot cover (meter mismatch, coarse + // counters) stays out of the cost model on both sides. + var consumption, gridToConsumption float64 + attribute(s, func(from, to string, energy float64) { + sums[[2]string{from, to}] += energy + if to != Home && to != Loadpoint { + return + } + if s.Grid != nil { + key := [2]string{from, to} + priced[key] += energy + if from == Grid { + costs[key] += energy * *s.Grid + } else if s.FeedIn != nil { + costs[key] += energy * *s.FeedIn + } + } + consumption += energy + if from == Grid { + gridToConsumption += energy + } + }) + + if s.Grid != nil { + cost.Import += s.GridIn * *s.Grid + cost.ImportEnergy += s.GridIn + cost.AvgGrid += *s.Grid + costSlots++ + if s.FeedIn != nil { + cost.Export += s.GridOut * *s.FeedIn + cost.ExportEnergy += s.GridOut + } + } + + if s.Co2 != nil { + co2.Consumption += gridToConsumption * *s.Co2 / 1e3 + co2.ConsumptionEnergy += consumption + co2.AvgCo2 += *s.Co2 + co2Slots++ + } + } + + if err := rows.Err(); err != nil { + return FlowResult{}, err + } + + var res FlowResult + for _, from := range flowSources { + for _, to := range flowSinks { + key := [2]string{from, to} + if e := roundEnergy(sums[key]); e > 0 { + res.Flows = append(res.Flows, Flow{From: from, To: to, Energy: e, Cost: costs[key], PricedEnergy: priced[key]}) + } + } + } + + if costSlots > 0 { + cost.AvgGrid /= costSlots + res.Cost = &cost + } + + if co2Slots > 0 { + co2.AvgCo2 /= co2Slots + co2.Baseline = co2.AvgCo2 * co2.ConsumptionEnergy / 1e3 + res.Co2 = &co2 + } + + return res, nil +} diff --git a/core/metrics/db_flow_test.go b/core/metrics/db_flow_test.go new file mode 100644 index 0000000000..cb26df9b2e --- /dev/null +++ b/core/metrics/db_flow_test.go @@ -0,0 +1,105 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func collectFlows(s flowSlot) map[string]float64 { + res := make(map[string]float64) + attribute(s, func(from, to string, energy float64) { + res[from+">"+to] += energy + }) + return res +} + +func TestAttribute(t *testing.T) { + for _, tc := range []struct { + name string + slot flowSlot + want map[string]float64 + }{ + { + "pv covers home, loadpoint and export", + flowSlot{PV: 10, Home: 2, Loadpoint: 3, GridOut: 5}, + map[string]float64{"pv>home": 2, "pv>loadpoint": 3, "pv>export": 5}, + }, + { + "battery charge before loadpoints, grid fills the rest", + flowSlot{PV: 4, Home: 2, BatIn: 3, Loadpoint: 6, GridIn: 7}, + map[string]float64{"pv>home": 2, "pv>battery": 2, "grid>battery": 1, "grid>loadpoint": 6}, + }, + { + "battery discharge after pv, never to battery", + flowSlot{PV: 1, BatOut: 3, BatIn: 1, Home: 2, Loadpoint: 1, GridIn: 1}, + map[string]float64{"pv>home": 1, "battery>home": 1, "battery>loadpoint": 1, "grid>battery": 1}, + }, + { + "grid never exports, mismatch dropped", + flowSlot{GridIn: 2, GridOut: 1, Home: 1}, + map[string]float64{"grid>home": 1}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, collectFlows(tc.slot)) + }) + } +} + +func TestQueryFlow(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + entities := make(map[string]entity) + for _, group := range []string{PV, Grid, Battery, Home, Loadpoint} { + e := entity{Name: group, Group: group} + require.NoError(t, db.Instance.FirstOrCreate(&e, entity{Name: group}).Error) + entities[group] = e + } + + base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.Now().Location()) + slot := func(i int) time.Time { return base.Add(time.Duration(i) * 15 * time.Minute) } + + // slot 0: sunny, export; slot 1: night, grid import with unattributed export; slot 2: outside range + require.NoError(t, persist(entities[PV], slot(0), 2, 0, nil, false)) + require.NoError(t, persist(entities[Home], slot(0), 0.5, 0, nil, false)) + require.NoError(t, persist(entities[Battery], slot(0), 0.5, 0, nil, false)) + require.NoError(t, persist(entities[Grid], slot(0), 0, 1, nil, false)) + + require.NoError(t, persist(entities[Grid], slot(1), 1, 0.5, nil, false)) + require.NoError(t, persist(entities[Battery], slot(1), 0, 0.5, nil, false)) + require.NoError(t, persist(entities[Home], slot(1), 0.5, 0, nil, false)) + require.NoError(t, persist(entities[Loadpoint], slot(1), 1, 0, nil, false)) + + require.NoError(t, persist(entities[Grid], slot(2), 5, 0, nil, false)) + require.NoError(t, persist(entities[Home], slot(2), 5, 0, nil, false)) + + // tariffs only for slot 1 + grid, feedin := 0.3, 0.1 + require.NoError(t, PersistTariffs(slot(1), &grid, &feedin, nil, nil)) + + res, err := QueryFlow(slot(0), slot(2)) + require.NoError(t, err) + + // only slot 1 is priced: battery to home at feed-in 0.1, grid to loadpoint at 0.3 + assert.Equal(t, []Flow{ + {From: PV, To: Home, Energy: 0.5}, + {From: PV, To: Battery, Energy: 0.5}, + {From: PV, To: Export, Energy: 1}, + {From: Battery, To: Home, Energy: 0.5, Cost: 0.05, PricedEnergy: 0.5}, + {From: Grid, To: Loadpoint, Energy: 1, Cost: 0.3, PricedEnergy: 1}, + }, res.Flows) + + require.NotNil(t, res.Cost) + assert.InDelta(t, 0.3, res.Cost.Import, 1e-9) + assert.InDelta(t, 0.05, res.Cost.Export, 1e-9) + assert.InDelta(t, 1, res.Cost.ImportEnergy, 1e-9) + assert.InDelta(t, 0.5, res.Cost.ExportEnergy, 1e-9) + assert.InDelta(t, 0.3, res.Cost.AvgGrid, 1e-9) + + assert.Nil(t, res.Co2) +} diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index 82be539f0f..883cd19791 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -19,7 +19,12 @@ type Slot struct { End time.Time `json:"end"` Energy float64 `json:"energy"` ReturnEnergy float64 `json:"returnEnergy"` - SocTemp *float64 `json:"socTemp,omitempty"` + SocTemp *float64 `json:"socTemp,omitempty"` // a single slot's value + // a price may not exist for every slot, priced energy is tracked separately to keep averages correct + Cost *float64 `json:"cost,omitempty"` // grid import, where a price exists + PricedEnergy *float64 `json:"pricedEnergy,omitempty"` // kWh with a price + ReturnCost *float64 `json:"returnCost,omitempty"` // grid export, where a price exists + PricedReturnEnergy *float64 `json:"pricedReturnEnergy,omitempty"` // kWh with a price } // roundEnergy rounds kWh to Wh precision and clamps negative noise to zero. @@ -49,6 +54,24 @@ var aggregateFormats = map[string]string{ "month": "%Y-%m", } +// aggregateStarts aligns a bucket's first slot to the bucket boundary, so all +// entities share the same bucket start +var aggregateStarts = map[string]func(time.Time) time.Time{ + "15m": func(t time.Time) time.Time { return t }, + "hour": func(t time.Time) time.Time { + t = t.Local() + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()) + }, + "day": func(t time.Time) time.Time { + t = t.Local() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + }, + "month": func(t time.Time) time.Time { + t = t.Local() + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) + }, +} + var aggregateDurations = map[string]func(time.Time) time.Time{ "15m": func(t time.Time) time.Time { return t.Add(15 * time.Minute) }, "hour": func(t time.Time) time.Time { return t.Add(time.Hour) }, @@ -107,6 +130,7 @@ func DeleteEnergy(from, to time.Time, filter ...EnergyFilter) (int64, error) { // QueryEnergy returns aggregated energy data, per title or per group. func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...EnergyFilter) ([]Series, error) { addDuration := aggregateDurations[aggregate] + bucketStart := aggregateStarts[aggregate] format, ok := aggregateFormats[aggregate] if !ok { @@ -131,10 +155,12 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...E ReturnEnergy float64 SocTemp *float64 IsTemp bool + + Cost, PricedEnergy, ReturnCost, PricedReturnEnergy *float64 } - // soc_temp reports the bucket's first slot; omitted for grouped sums - socCols := `, m.soc_temp AS soc_temp, e.is_temp AS is_temp` + // soc_temp only for a single slot bucket; omitted for grouped sums + socCols := `, CASE WHEN COUNT(*) = 1 THEN m.soc_temp END AS soc_temp, e.is_temp AS is_temp` if grouped { socCols = `` } @@ -143,8 +169,13 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...E Select(selectTitle + `, e."group", MIN(m.ts) AS start, COALESCE(SUM(m.energy), 0) AS energy, - COALESCE(SUM(m.return_energy), 0) AS return_energy` + socCols). + COALESCE(SUM(m.return_energy), 0) AS return_energy, + SUM(CASE WHEN e."group" = 'grid' THEN m.energy * t.grid END) AS cost, + SUM(CASE WHEN e."group" = 'grid' AND t.grid IS NOT NULL THEN m.energy END) AS priced_energy, + SUM(CASE WHEN e."group" = 'grid' THEN m.return_energy * t.feedin END) AS return_cost, + SUM(CASE WHEN e."group" = 'grid' AND t.feedin IS NOT NULL THEN m.return_energy END) AS priced_return_energy` + socCols). Joins("JOIN entities e ON m.meter = e.id"). + Joins("LEFT JOIN tariffs t ON t.ts = m.ts"). Group(groupCols). Order(groupCols) @@ -173,12 +204,18 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...E } s := &res[len(res)-1] + start := bucketStart(time.Time(r.Start)) s.Data = append(s.Data, Slot{ - Start: time.Time(r.Start), - End: addDuration(time.Time(r.Start)), + Start: start, + End: addDuration(start), Energy: roundEnergy(r.Energy), ReturnEnergy: roundEnergy(r.ReturnEnergy), SocTemp: r.SocTemp, + + Cost: r.Cost, + PricedEnergy: r.PricedEnergy, + ReturnCost: r.ReturnCost, + PricedReturnEnergy: r.PricedReturnEnergy, }) } diff --git a/core/metrics/db_history_test.go b/core/metrics/db_history_test.go index bb13237f3e..83068ee4a2 100644 --- a/core/metrics/db_history_test.go +++ b/core/metrics/db_history_test.go @@ -181,17 +181,66 @@ func TestQueryEnergySoc(t *testing.T) { from := base.Add(-time.Hour).UTC() to := base.Add(time.Hour).UTC() - // hourly bucket reports the first slot's snapshot, not an average + // an aggregated bucket has no soc, a single slot carries its value res, err := QueryEnergy(from, to, "hour", false) require.NoError(t, err) require.Len(t, res, 1) require.Len(t, res[0].Data, 1) - require.Equal(t, 80.0, *res[0].Data[0].SocTemp) + require.Nil(t, res[0].Data[0].SocTemp) require.False(t, res[0].IsTemp) // battery: value is soc + res, err = QueryEnergy(from, to, "15m", false) + require.NoError(t, err) + require.Len(t, res[0].Data, 2) + require.Equal(t, 80.0, *res[0].Data[0].SocTemp) + // grouped sums omit the per-entity snapshot res, err = QueryEnergy(from, to, "hour", true) require.NoError(t, err) require.Len(t, res, 1) require.Nil(t, res[0].Data[0].SocTemp) } + +func TestQueryEnergyGridCost(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + grid := entity{Name: "grid", Group: Grid} + require.NoError(t, db.Instance.Create(&grid).Error) + pv := entity{Name: "pv", Group: PV} + require.NoError(t, db.Instance.Create(&pv).Error) + + base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.Now().Location()) + slot := func(i int) time.Time { return base.Add(time.Duration(i) * 15 * time.Minute) } + + // import 1 kWh at 0.20, 3 kWh at 0.40, 1 kWh unpriced; export 2 kWh at 0.10 + require.NoError(t, persist(grid, slot(0), 1, 2, nil, false)) + require.NoError(t, persist(grid, slot(1), 3, 0, nil, false)) + require.NoError(t, persist(grid, slot(2), 1, 0, nil, false)) + require.NoError(t, persist(pv, slot(0), 5, 0, nil, false)) + require.NoError(t, PersistTariffs(slot(0), new(0.2), new(0.1), nil, nil)) + require.NoError(t, PersistTariffs(slot(1), new(0.4), nil, nil, nil)) + + res, err := QueryEnergy(base.Add(-time.Hour), base.Add(time.Hour), "hour", false) + require.NoError(t, err) + require.Len(t, res, 2) + + // the bucket sums slot energy times slot price, unpriced slots stay out + g := res[0].Data[0] + require.Equal(t, Grid, res[0].Group) + require.InDelta(t, 5, g.Energy, 1e-9) + require.InDelta(t, 1.4, *g.Cost, 1e-9) + require.InDelta(t, 4, *g.PricedEnergy, 1e-9) + require.InDelta(t, 0.2, *g.ReturnCost, 1e-9) + require.InDelta(t, 2, *g.PricedReturnEnergy, 1e-9) + + // other groups carry no cost + require.Nil(t, res[1].Data[0].Cost) + + // a bucket starts at its boundary, not at the entity's first slot + res, err = QueryEnergy(base.Add(-time.Hour), base.Add(time.Hour), "day", false) + require.NoError(t, err) + midnight := time.Date(2026, 4, 15, 0, 0, 0, 0, time.Now().Location()) + require.True(t, midnight.Equal(res[0].Data[0].Start)) + require.True(t, midnight.AddDate(0, 0, 1).Equal(res[0].Data[0].End)) +} diff --git a/core/metrics/tariffs.go b/core/metrics/tariffs.go index 1f93d38cdc..614cae190f 100644 --- a/core/metrics/tariffs.go +++ b/core/metrics/tariffs.go @@ -88,3 +88,52 @@ func PersistTariffs(ts time.Time, grid, feedin, co2, temperature *float64) error Temperature: temperature, }).Error } + +// TariffSlot is one persisted 15 minute slot of tariff values, or the average +// of a bucket with the slot range when aggregated +type TariffSlot struct { + Timestamp int64 `json:"-" gorm:"column:ts"` + Start time.Time `json:"start" gorm:"-"` + Grid *float64 `json:"grid,omitempty"` + GridMin *float64 `json:"gridMin,omitempty"` + GridMax *float64 `json:"gridMax,omitempty"` + FeedIn *float64 `json:"feedin,omitempty"` + FeedInMin *float64 `json:"feedinMin,omitempty"` + FeedInMax *float64 `json:"feedinMax,omitempty"` + Co2 *float64 `json:"co2,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` +} + +// QueryTariffs returns the persisted slots in [from,to), zero bounds are open. +// With an aggregate of hour, day or month the slots are averaged per bucket. +func QueryTariffs(from, to time.Time, aggregate string) ([]TariffSlot, error) { + tx := db.Instance.Table("tariffs").Order("ts") + if !from.IsZero() { + tx = tx.Where("ts >= ?", from.Unix()) + } + if !to.IsZero() { + tx = tx.Where("ts < ?", to.Unix()) + } + + if aggregate == "" || aggregate == "15m" { + tx = tx.Select("ts, grid, feedin AS feed_in, co2, temperature") + } else { + format, ok := aggregateFormats[aggregate] + if !ok { + return nil, fmt.Errorf("invalid aggregate: %s", aggregate) + } + tx = tx.Select(`MIN(ts) AS ts, + AVG(grid) AS grid, MIN(grid) AS grid_min, MAX(grid) AS grid_max, + AVG(feedin) AS feed_in, MIN(feedin) AS feed_in_min, MAX(feedin) AS feed_in_max`). + Group(fmt.Sprintf(`strftime('%s', ts, 'unixepoch', 'localtime')`, format)) + } + + var res []TariffSlot + if err := tx.Scan(&res).Error; err != nil { + return nil, err + } + for i := range res { + res[i].Start = time.Unix(res[i].Timestamp, 0) + } + return res, nil +} diff --git a/core/metrics/tariffs_test.go b/core/metrics/tariffs_test.go index cc64b20745..55db801e28 100644 --- a/core/metrics/tariffs_test.go +++ b/core/metrics/tariffs_test.go @@ -53,3 +53,30 @@ func TestPersistTariffs(t *testing.T) { require.InDelta(t, 250, *res.Co2, 0.001) require.InDelta(t, 21.5, *res.Temperature, 0.001) } + +func TestQueryTariffs(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, db.Instance.AutoMigrate(new(tariffValue))) + + slot := time.Date(2026, 4, 15, 16, 0, 0, 0, time.UTC) + grid := 0.3 + for i := range 3 { + v := grid + float64(i)/10 + require.NoError(t, PersistTariffs(slot.Add(time.Duration(i)*15*time.Minute), &v, nil, nil, nil)) + } + + res, err := QueryTariffs(slot.Add(15*time.Minute), slot.Add(45*time.Minute), "") + require.NoError(t, err) + require.Len(t, res, 2) + require.Equal(t, slot.Add(15*time.Minute).Unix(), res[0].Start.Unix()) + require.InDelta(t, 0.4, *res[0].Grid, 0.001) + require.Nil(t, res[0].FeedIn) + + // one hour bucket: average with the slot range + agg, err := QueryTariffs(slot, slot.Add(time.Hour), "hour") + require.NoError(t, err) + require.Len(t, agg, 1) + require.InDelta(t, 0.4, *agg[0].Grid, 0.001) + require.InDelta(t, 0.3, *agg[0].GridMin, 0.001) + require.InDelta(t, 0.5, *agg[0].GridMax, 0.001) +} diff --git a/docs/agents/energy-analytics.md b/docs/agents/energy-analytics.md new file mode 100644 index 0000000000..8c690f68b6 --- /dev/null +++ b/docs/agents/energy-analytics.md @@ -0,0 +1,120 @@ +# Energy Analytics + +How the Energy page turns 15-minute meter slots into attribution, cost, CO2, autarky and forecast accuracy. This is the conceptual model and the reasoning behind it. The code in `core/metrics/` and `assets/js/views/Energy.vue` is the reference for details. + +## Data model + +Everything derives from two tables written every 15 minutes: + +- **Meter slots**: per entity `energy` and `returnEnergy` in kWh, optionally SoC or temperature. Entities belong to a group: `pv`, `grid`, `battery`, `home`, `loadpoint`, `consumer`, `meter`, `forecast`, `temperature`. +- **Tariff slots**: grid price, feed-in price, CO2 intensity and outside temperature at the same slot boundaries. Each value is optional, a slot exists as soon as one is known. + +Directions per group: + +| group | energy | returnEnergy | +| --------- | ----------- | ------------ | +| grid | import | export | +| battery | charge | discharge | +| pv | production | unused | +| home | consumption | unused | +| loadpoint | charged | unused | +| consumer | consumed | unused | +| meter | metered | reverse | + +`home` is not a meter. It is the residual of the live power balance: grid + pv + battery discharge minus loadpoint charging, clamped at zero, integrated per slot. It is the whole house load excluding loadpoints. Consumers are sub-meters of home; `meter` entities are monitoring only and never enter any balance. + +Everything works on slots, never on session records. Sessions use an instantaneous solar share, the slot model nets import and export inside 15 minutes. The two will disagree on "solar %", by design. The Energy page follows the slot model, Sessions stay as they are. + +## Source attribution + +Each slot has sources (PV production, battery discharge, grid import) and sinks (home, battery charge, loadpoints, export). Which source fed which sink is not measurable, so it is assigned by a priority rule, applied per slot and summed over the period: + +1. Sources in order PV, battery, grid. +2. Sinks in order home, battery charge, loadpoints, export. +3. Walk sources outer, sinks inner, moving `min(remaining source, remaining sink)` each step. Battery does not feed itself, grid does not feed export. + +This is the same rule the live control loop uses for the green share, so the page and the running system tell the same story. Home comes before loadpoints because the house load is not steerable while charging is. Anything a slot's sources cannot cover (meter mismatch, coarse counters, missing entity) is silently dropped rather than invented. Attributed totals are therefore always at or below the metered totals. + +Everything downstream is built on these flows: the flow chart, the source mix bars (share of PV, battery, grid in what reached a sink), autarky, cost and CO2. + +The overview bar chart uses a simpler per-bucket split on the client: production covers export first, then battery charging, the remainder is self-consumption. The usage view keeps that column, from minus import and discharge up to production, and fills it with sinks instead: export on top, then consumers, each loadpoint, each battery at the bottom, each with all it received from production and import alike. The baseline just cuts through the stack. Energy no sink explains stays with consumption, a sink no source covers is not drawn. + +## Metered versus attributed + +Headline numbers that a user can compare against their meter (grid import, grid export, production, home consumption, battery charge and discharge) are metered sums. Numbers that describe relationships (autarky, self-consumption, source mix, savings) are derived from the attribution. The two can differ slightly, which is accepted. A cost box never claims to explain more energy than the tariff-covered attributed energy. + +## Derived ratios + +- **Autarky** = 1 - grid share of what reached home and loadpoints. +- **Self-consumption** = 1 - export share of PV production. +- **Source mix per sink** = share of each source in the energy that reached that sink. + +All ratios are clamped to 0 to 100 percent and fall back to zero when the denominator is zero. + +## Cost and CO2 + +Prices are applied at slot level and summed, never as period average times energy. A day of cheap solar noon plus expensive evening import must not be priced at the daily mean. + +Per slot with a grid price: + +- Import cost = import × grid price. Export revenue = export × feed-in price. +- **Consumption cost**: grid energy that reached home or loadpoints at the grid price, plus self-produced energy that reached them at the feed-in price. The feed-in price is the opportunity cost: that kWh could have been sold. +- Each flow into home or loadpoints carries its own cost and priced energy under the same rule. Consumption cost is the sum of these flows, and it can be split by source. +- **Baseline**: the same consumed energy at the period's average grid price, as if there were no PV and no battery. The page derives it from the average grid price and the priced consumption. +- **Savings** = baseline - consumption cost. + +CO2 works the same way with grid intensity, but self-produced energy counts as zero, so consumption CO2 is only the grid share. + +Slots without a price (or CO2 value) are excluded from the cost model on both sides. Every cost is therefore reported together with the energy it covers. The displayed "priced energy" is what the savings figure covers, so a partially priced period is visibly smaller instead of wrong. An average price is always cost divided by priced energy, never by all energy: the month in which price recording began would otherwise show a price that is far too low. The effective price per kWh shown next to savings is consumption cost divided by priced consumption, compared with the average grid price. + +The grid history carries the same pair per bucket: import cost and feed-in revenue with their priced energy. The grid chart uses them for the amount and the effective price of a day or month. + +Per-entity cost uses average sink rates, not slot detail: every consumer is priced at the average rate of home, every loadpoint at the average rate of the non-home consumption. The source breakdown of an entity works the same way: it inherits the source shares and prices of its sink, scaled to its energy. Sub-meters are not attributed individually because their slot pattern would have to be matched against the home slot pattern with no gain in trust. + +## Forecast accuracy + +The PV forecast is persisted per slot alongside the meters. Accuracy for a finished period: + +- Per bucket (slot, day or month depending on the view), error = |forecast - produced|. +- Accuracy = 1 - Σ error / Σ forecast. + +Absolute error per bucket is used so that an over-forecast morning and an under-forecast afternoon do not cancel out. The total delta (forecast minus produced) is shown separately as "x kWh above/below" so the user sees both the shape error and the level bias. In the chart the same data is drawn as floating segments from the forecast value to the produced value. + +For the running day the accuracy is not yet meaningful, so the tile shows the remaining expected production instead. + +Buckets that have no persisted forecast slot (restart gaps, forecast provider outage) are skipped entirely: they carry no error, no anchor and are not drawn. Showing a full-height bar for "forecast unknown, production known" would read as a giant miss. + +The forecast source changed over time in real installations. Comparing accuracy across periods with different providers is not supported, and there is no "valid since" marker for forecast quality. + +## Missing, partial and implausible data + +Principles: + +- **Hide, do not fake.** A card whose group has no metered energy in the period is not rendered. The page's empty state keys on any metered energy, not on attribution, so a PV-only or battery-only setup still gets a page. +- **Show what is covered.** Cost and CO2 tiles print the priced energy they explain. Without any tariff data they show a "no price data" state with a link to tariff configuration instead of zeros. +- **Drop, do not stretch.** Attribution never scales a source up to meet a sink. Unexplained energy disappears from ratios rather than being assigned to grid by default. +- **Clamp residuals at zero.** "Others" (home minus tracked consumers) is clamped per slot so meter noise or a consumer briefly exceeding home never produces negative consumption. +- **Slot gaps are tolerated in sums.** A day with 91 to 95 slots after a restart sums fine. Recovered slots (downtime energy written into one slot) are kept in sums but excluded from profile averages, where they would distort the shape. + +Known data quality traits that are accepted, not fixed: + +- Chargers that report energy hourly (Easee) lump an hour into one slot. Daily sums are right, slot-level solar share and cost for those loadpoints are approximate. +- Battery counters with coarse resolution can show cumulative discharge above charge. No efficiency figure is derived from them. Throughput and SoC are fine. +- Entities have different "valid since" dates. Autarky without a grid meter, or savings before tariffs were persisted, is simply absent or covers less energy. + +## Tariff aggregation for charts + +The tariff read path can aggregate slots per hour, day or month, returning the mean plus min and max per bucket. These aggregates are display context only. Cost figures always come from the slot-level model above. + +The grid chart shows prices in a panel below the bars. The day view draws the raw 15-minute tariff. Month and year views draw the effective price per bucket, which is the bucket's cost divided by its priced energy. A plain per-day tariff average would be a constant for a fixed two-zone tariff and hide when energy was actually bought cheap or expensive. A bucket without energy falls back to the tariff average so the line stays continuous. + +A price is only charted when it changes over the period. A constant price still appears in the legend and the tooltip. The legend shows the tariff range for a day and the average price paid for longer periods. + +## Deciding new metrics + +Before adding a number to the page: + +1. Is it a metered sum or an attribution result? Label it so it does not get compared against the wrong thing. +2. What does it show when the underlying entity or tariff is absent? Hide or state coverage. No zeros that look like measurements. +3. Does it cancel errors (net delta) where per-bucket errors matter (absolute)? Pick deliberately. +4. Can slot-level pricing express it? If it needs average price times energy, it belongs in a subline, not a headline. diff --git a/i18n/ca.json b/i18n/ca.json index de2a4d1318..87fe8193dd 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -1228,6 +1228,31 @@ "restartDescription": "Heu provat d’apagar i tornar a engegar?", "secondaryActions": "Encara no heu pogut resoldre el problema? Aquí teniu altres opcions més contundents." }, + "energy": { + "direction": { + "battery": { + "energy": "carregada", + "returnEnergy": "descarregada" + }, + "grid": { + "energy": "importada", + "returnEnergy": "exportada" + }, + "meter": { + "energy": "Energia", + "returnEnergy": "Energia (inversa)" + } + }, + "group": { + "battery": "Bateria", + "consumer": "Consum", + "forecast": "Previsió", + "grid": "Xarxa", + "loadpoint": "Càrrega i calefacció", + "meter": "Comptadors addicionals", + "pv": "Producció" + } + }, "issue": { "additional": { "description": "Incloeu configuració i registres per a ajudar-nos a reproduir el problema ràpidament. Us recomanem compartir tot el possible. Normalment no cal l’estat.", @@ -1393,35 +1418,6 @@ "feedIn": "Injecció", "title": "Límit extern actiu" }, - "history": { - "direction": { - "battery": { - "energy": "carregada", - "returnEnergy": "descarregada" - }, - "grid": { - "energy": "importada", - "returnEnergy": "exportada" - }, - "meter": { - "energy": "Energia", - "returnEnergy": "Energia (inversa)" - } - }, - "download": "Descarrega", - "empty": "No hi ha dades d’historial per a aquest interval de temps.", - "group": { - "battery": "Bateria", - "consumer": "Consum", - "forecast": "Previsió", - "grid": "Xarxa", - "loadpoint": "Càrrega i calefacció", - "meter": "Comptadors addicionals", - "pv": "Producció" - }, - "otherConsumers": "Altres", - "title": "Historial" - }, "loadpoint": { "avgPrice": "Preu ⌀", "charged": "Carregat", diff --git a/i18n/cs.json b/i18n/cs.json index 66afc89d6b..0283c8d6a0 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -1197,6 +1197,31 @@ "restartDescription": "Zkusili jste to restartovat?", "secondaryActions": "Stále se vám nedaří vyřešit váš problém? Zde jsou některé další možnosti." }, + "energy": { + "direction": { + "battery": { + "energy": "nabito", + "returnEnergy": "vybito" + }, + "grid": { + "energy": "nakoupeno z distribuce", + "returnEnergy": "prodáno do distribuce" + }, + "meter": { + "energy": "Energie", + "returnEnergy": "Energie (reverse)" + } + }, + "group": { + "battery": "Baterie", + "consumer": "Spotřeba", + "forecast": "Předpověď", + "grid": "Distribuční síť", + "loadpoint": "Nabíjení a vytápění", + "meter": "Ďalší měřiče", + "pv": "Výroba" + } + }, "issue": { "additional": { "description": "Vložte detaily konfigurace a protokoly jež pomohou reprodukovat problém. Žádáme o co největší sdílnost.", @@ -1359,35 +1384,6 @@ "feedIn": "Export", "title": "Externí limit: aktívní" }, - "history": { - "direction": { - "battery": { - "energy": "nabito", - "returnEnergy": "vybito" - }, - "grid": { - "energy": "nakoupeno z distribuce", - "returnEnergy": "prodáno do distribuce" - }, - "meter": { - "energy": "Energie", - "returnEnergy": "Energie (reverse)" - } - }, - "download": "Stáhnout", - "empty": "Pro zvolený úsek nejsou k dispozici historická data.", - "group": { - "battery": "Baterie", - "consumer": "Spotřeba", - "forecast": "Předpověď", - "grid": "Distribuční síť", - "loadpoint": "Nabíjení a vytápění", - "meter": "Ďalší měřiče", - "pv": "Výroba" - }, - "otherConsumers": "Ostatní", - "title": "Historie" - }, "loadpoint": { "avgPrice": "⌀ Cena", "charged": "Nabito", diff --git a/i18n/da.json b/i18n/da.json index 6d6fbeac16..656c97a448 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -1230,6 +1230,31 @@ "restartDescription": "Har du prøvet at slukke og tænde den igen?", "secondaryActions": "Stadig ikke i stand til at løse dit problem? Her er nogle mere ekstreme muligheder." }, + "energy": { + "direction": { + "battery": { + "energy": "opladet", + "returnEnergy": "afladet" + }, + "grid": { + "energy": "importeret", + "returnEnergy": "eksporteret" + }, + "meter": { + "energy": "Energi", + "returnEnergy": "Energi (eksport)" + } + }, + "group": { + "battery": "Batteri", + "consumer": "Forbrug", + "forecast": "Prognose", + "grid": "Elnet", + "loadpoint": "Opladning og opvarmning", + "meter": "Ekstra målere", + "pv": "Produktion" + } + }, "issue": { "additional": { "copied": "kopieret!", @@ -1407,35 +1432,6 @@ "feedIn": "Indfødning", "title": "Ekstern begrænsning er aktiv" }, - "history": { - "direction": { - "battery": { - "energy": "opladet", - "returnEnergy": "afladet" - }, - "grid": { - "energy": "importeret", - "returnEnergy": "eksporteret" - }, - "meter": { - "energy": "Energi", - "returnEnergy": "Energi (eksport)" - } - }, - "download": "Download", - "empty": "Ingen historik i dette tidsinterval.", - "group": { - "battery": "Batteri", - "consumer": "Forbrug", - "forecast": "Prognose", - "grid": "Elnet", - "loadpoint": "Opladning og opvarmning", - "meter": "Ekstra målere", - "pv": "Produktion" - }, - "otherConsumers": "Andet", - "title": "Historik" - }, "loadpoint": { "avgPrice": "⌀ pris", "charged": "Opladet", diff --git a/i18n/de.json b/i18n/de.json index 282e7c2828..474fa23b8c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1293,6 +1293,123 @@ "restartDescription": "Schon versucht, das Gerät aus- und wieder einzuschalten?", "secondaryActions": "Noch keine Lösung gefunden? Hier sind noch ein paar weitere Möglichkeiten." }, + "energy": { + "battery": { + "charged": "Geladen", + "discharged": "Entladen" + }, + "consumers": { + "chart": { + "bars": "Balken", + "pattern": "Muster" + }, + "cost": "Kosten", + "others": "Sonstige", + "source": { + "pv": "Solar", + "battery": "Speicher", + "grid": "Netz" + }, + "title": "Verbraucher", + "view": { + "river": "River", + "treemap": "Treemap" + } + }, + "direction": { + "battery": { + "energy": "geladen", + "returnEnergy": "entladen" + }, + "grid": { + "energy": "Bezug", + "returnEnergy": "Einspeisung" + }, + "meter": { + "energy": "Energie", + "returnEnergy": "Energie (rückwärts)" + } + }, + "empty": "Keine Energiedaten für diesen Zeitraum verfügbar.", + "flow": { + "battery": "Batterie", + "batteryCharge": "Batterieladung", + "batteryDischarge": "Batterieentladung", + "charging": "Laden", + "export": "Einspeisung", + "grid": "Netzbezug", + "heating": "Heizen", + "home": "Verbrauch", + "overview": "Übersicht", + "pv": "Erzeugung", + "title": "Energiefluss", + "usage": "Verwendung", + "withdrawal": "Entnahme" + }, + "grid": { + "amount": "Betrag", + "cost": "Netzkosten", + "energy": "Energie", + "export": "Einspeisung", + "exportPrice": "Einspeisepreis", + "import": "Bezug", + "importPrice": "Bezugspreis", + "price": "Preis", + "revenue": "Einspeisevergütung", + "title": "Netz" + }, + "group": { + "battery": "Batterie", + "consumer": "Verbrauch", + "forecast": "Vorhersage", + "grid": "Netz", + "meter": "Weitere Zähler", + "pv": "Erzeugung" + }, + "production": { + "above": "{value} darüber", + "accuracy": "Vorhersagegenauigkeit", + "below": "{value} darunter", + "deviation": "Abweichung", + "expectedTomorrow": "morgen erwartet", + "exported": "{value} eingespeist", + "produced": "Erzeugt", + "remainingToday": "heute verbleibend", + "selfConsumed": "Selbst verbraucht", + "title": "Erzeugung" + }, + "socTemp": { + "charged": "geladen", + "soc": "Ladung", + "temperature": "Temperatur", + "used": "verbraucht" + }, + "sources": { + "battery": "Batterie", + "cost": "Kosten", + "energy": "Energie", + "grid": "Netz", + "hint": "Solar- und Batteriestrom zum Einspeisepreis bewertet.", + "price": "Preis/kWh", + "pv": "Solar", + "share": "Anteil", + "source": "Quelle", + "title": "Quellen", + "total": "Gesamt" + }, + "stat": { + "autarky": "Autarkie", + "co2Saved": "CO₂ gespart", + "co2SavedTooltip": "Im Vergleich zum Bezug der verbrauchten {energy} aus dem Netz.", + "configure": "Jetzt einrichten", + "noCo2": "Keine CO₂-Daten.", + "noPrice": "Keine Preisdaten.", + "savings": "Ersparnis", + "savingsTooltip": "Im Vergleich zum Bezug der verbrauchten {energy} aus dem Netz.", + "versus": "{value} statt {reference}" + }, + "title": "Energie" + }, "issue": { "additional": { "copied": "Kopiert!", @@ -1470,35 +1587,6 @@ "feedIn": "Einspeisung", "title": "Externe Begrenzung aktiv" }, - "history": { - "direction": { - "battery": { - "energy": "geladen", - "returnEnergy": "entladen" - }, - "grid": { - "energy": "bezogen", - "returnEnergy": "eingespeist" - }, - "meter": { - "energy": "Energie", - "returnEnergy": "Energie (rückwärts)" - } - }, - "download": "Download", - "empty": "Keine Verlaufsdaten für diesen Zeitraum.", - "group": { - "battery": "Batterie", - "consumer": "Verbrauch", - "forecast": "Vorhersage", - "grid": "Netz", - "loadpoint": "Laden & Heizen", - "meter": "Weitere Zähler", - "pv": "Erzeugung" - }, - "otherConsumers": "Andere", - "title": "Historie" - }, "loadpoint": { "avgPrice": "⌀ Preis", "charged": "Geladen", @@ -1930,6 +2018,7 @@ "battery": "Batterie", "charge": "Laden", "comingSoon": "Diese Seite ist in Arbeit.", + "energy": "Energie", "forecast": "Vorhersage", "more": "Mehr", "sessions": "Ladungen" diff --git a/i18n/en.json b/i18n/en.json index 36ee92e239..081d812673 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1293,6 +1293,123 @@ "restartDescription": "Tried turning it off and on again?", "secondaryActions": "Still not able to solve your problem? Here are some more heavy-handed options." }, + "energy": { + "battery": { + "charged": "Charged", + "discharged": "Discharged" + }, + "consumers": { + "chart": { + "bars": "Bars", + "pattern": "Pattern" + }, + "cost": "Cost", + "others": "Others", + "source": { + "pv": "solar", + "battery": "battery", + "grid": "grid" + }, + "title": "Consumers", + "view": { + "river": "River", + "treemap": "Treemap" + } + }, + "direction": { + "battery": { + "energy": "charged", + "returnEnergy": "discharged" + }, + "grid": { + "energy": "Import", + "returnEnergy": "Export" + }, + "meter": { + "energy": "Energy", + "returnEnergy": "Energy (reverse)" + } + }, + "empty": "No energy data available for this time range.", + "flow": { + "battery": "Battery", + "batteryCharge": "Battery charge", + "batteryDischarge": "Battery discharge", + "charging": "Charging", + "export": "Grid export", + "grid": "Grid import", + "heating": "Heating", + "home": "Consumption", + "overview": "Overview", + "pv": "Production", + "title": "Energy flow", + "usage": "Usage", + "withdrawal": "Withdrawal" + }, + "grid": { + "amount": "Amount", + "cost": "Grid cost", + "energy": "Energy", + "export": "Export", + "exportPrice": "Export price", + "import": "Import", + "importPrice": "Import price", + "price": "Price", + "revenue": "Feed-in revenue", + "title": "Grid" + }, + "group": { + "battery": "Battery", + "consumer": "Consumption", + "forecast": "Forecast", + "grid": "Grid", + "meter": "Additional meters", + "pv": "Production" + }, + "production": { + "above": "{value} above", + "accuracy": "Forecast accuracy", + "below": "{value} below", + "deviation": "Deviation", + "expectedTomorrow": "expected tomorrow", + "exported": "{value} exported", + "produced": "Produced", + "remainingToday": "remaining today", + "selfConsumed": "Self-consumed", + "title": "Production" + }, + "socTemp": { + "charged": "charged", + "soc": "charge", + "temperature": "temperature", + "used": "used" + }, + "sources": { + "battery": "Battery", + "cost": "Cost", + "energy": "Energy", + "grid": "Grid", + "hint": "Solar and battery energy valued at feed-in price.", + "price": "Price/kWh", + "pv": "Solar", + "share": "Share", + "source": "Source", + "title": "Sources", + "total": "Total" + }, + "stat": { + "autarky": "Autarky", + "co2Saved": "CO₂ saved", + "co2SavedTooltip": "Compared to drawing the consumed {energy} from the grid.", + "configure": "Configure now", + "noCo2": "No CO₂ data.", + "noPrice": "No price data.", + "savings": "Savings", + "savingsTooltip": "Compared to buying the consumed {energy} from the grid.", + "versus": "{value} vs. {reference}" + }, + "title": "Energy" + }, "issue": { "additional": { "copied": "copied!", @@ -1470,35 +1587,6 @@ "feedIn": "Feed-in", "title": "External limit active" }, - "history": { - "direction": { - "battery": { - "energy": "charged", - "returnEnergy": "discharged" - }, - "grid": { - "energy": "imported", - "returnEnergy": "exported" - }, - "meter": { - "energy": "Energy", - "returnEnergy": "Energy (reverse)" - } - }, - "download": "Download", - "empty": "No history data available for this time range.", - "group": { - "battery": "Battery", - "consumer": "Consumption", - "forecast": "Forecast", - "grid": "Grid", - "loadpoint": "Charging & Heating", - "meter": "Additional meters", - "pv": "Production" - }, - "otherConsumers": "Others", - "title": "History" - }, "loadpoint": { "avgPrice": "⌀ Price", "charged": "Charged", @@ -1930,6 +2018,7 @@ "battery": "Battery", "charge": "Charge", "comingSoon": "This page is under construction.", + "energy": "Energy", "forecast": "Forecast", "more": "More", "sessions": "Sessions" diff --git a/i18n/fi.json b/i18n/fi.json index 016a394f79..16d1985c83 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -1230,6 +1230,31 @@ "restartDescription": "Yrititkö sammuttaa ja käynnistää uudelleen?", "secondaryActions": "Etkö vieläkään pysty ratkaisemaan ongelmaasi? Tässä on joitain raskaampia vaihtoehtoja." }, + "energy": { + "direction": { + "battery": { + "energy": "ladattu", + "returnEnergy": "käytetty" + }, + "grid": { + "energy": "ladattu verkosta", + "returnEnergy": "syötetty verkkoon" + }, + "meter": { + "energy": "Energia", + "returnEnergy": "Energia (käänteinen)" + } + }, + "group": { + "battery": "Akku", + "consumer": "Kulutus", + "forecast": "Ennuste", + "grid": "Sähköverkko", + "loadpoint": "Lataus & lämmitys", + "meter": "Lisämittarit", + "pv": "Tuotanto" + } + }, "issue": { "additional": { "copied": "kopioitu!", @@ -1407,35 +1432,6 @@ "feedIn": "Verkkoon syöttö", "title": "Ulkoinen raja: aktiivinen" }, - "history": { - "direction": { - "battery": { - "energy": "ladattu", - "returnEnergy": "käytetty" - }, - "grid": { - "energy": "ladattu verkosta", - "returnEnergy": "syötetty verkkoon" - }, - "meter": { - "energy": "Energia", - "returnEnergy": "Energia (käänteinen)" - } - }, - "download": "Lataa", - "empty": "Tälle ajanjaksolle ei ole saatavilla historiadataa.", - "group": { - "battery": "Akku", - "consumer": "Kulutus", - "forecast": "Ennuste", - "grid": "Sähköverkko", - "loadpoint": "Lataus & lämmitys", - "meter": "Lisämittarit", - "pv": "Tuotanto" - }, - "otherConsumers": "Muut", - "title": "Tilastot" - }, "loadpoint": { "avgPrice": "⌀ Hinta", "charged": "Ladattu", diff --git a/i18n/fr.json b/i18n/fr.json index 7fcbcb16ca..466dc1294a 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1230,6 +1230,31 @@ "restartDescription": "Avez-vous essayé de redémarrer ?", "secondaryActions": "Toujours pas de solution ? Voici quelques options plus avancées." }, + "energy": { + "direction": { + "battery": { + "energy": "chargée", + "returnEnergy": "déchargée" + }, + "grid": { + "energy": "importée", + "returnEnergy": "exportée" + }, + "meter": { + "energy": "Énergie", + "returnEnergy": "Énergie (inversée)" + } + }, + "group": { + "battery": "Batterie", + "consumer": "Consommation", + "forecast": "Prévisions", + "grid": "Réseau", + "loadpoint": "Chargement & Chauffage", + "meter": "Compteurs supplémentaires", + "pv": "Production" + } + }, "issue": { "additional": { "copied": "copié !", @@ -1407,35 +1432,6 @@ "feedIn": "Entrée", "title": "Limite externe active" }, - "history": { - "direction": { - "battery": { - "energy": "chargée", - "returnEnergy": "déchargée" - }, - "grid": { - "energy": "importée", - "returnEnergy": "exportée" - }, - "meter": { - "energy": "Énergie", - "returnEnergy": "Énergie (inversée)" - } - }, - "download": "Télécharger", - "empty": "Pas de données historiques pour cette intervalle de temps.", - "group": { - "battery": "Batterie", - "consumer": "Consommation", - "forecast": "Prévisions", - "grid": "Réseau", - "loadpoint": "Chargement & Chauffage", - "meter": "Compteurs supplémentaires", - "pv": "Production" - }, - "otherConsumers": "Autres", - "title": "Historique" - }, "loadpoint": { "avgPrice": "Prix ⌀", "charged": "Chargé", diff --git a/i18n/ja.json b/i18n/ja.json index 1e7f449f3c..35409cdf60 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -978,6 +978,26 @@ "restartDescription": "一度オフにして、もう一度オンにしてみましたか?", "secondaryActions": "それでも問題が解決しない場合は、以下の方法を試してみてください。" }, + "energy": { + "direction": { + "battery": { + "energy": "充電", + "returnEnergy": "放電" + }, + "grid": { + "energy": "買電", + "returnEnergy": "売電" + } + }, + "group": { + "battery": "蓄電池", + "forecast": "予測", + "grid": "系統", + "loadpoint": "充電・ヒーター", + "meter": "消費電力", + "pv": "発電" + } + }, "issue": { "additional": { "description": "問題を迅速に再現できるよう、設定とログを含めてください。できるだけ多くの情報を共有していただくことを推奨しています。通常、ステータス情報は必要ありません。", @@ -1118,29 +1138,6 @@ "hemsWarning": { "title": "外部システム制限:" }, - "history": { - "direction": { - "battery": { - "energy": "充電", - "returnEnergy": "放電" - }, - "grid": { - "energy": "買電", - "returnEnergy": "売電" - } - }, - "empty": "指定された期間のデータが存在しません。", - "group": { - "battery": "蓄電池", - "forecast": "予測", - "grid": "系統", - "loadpoint": "充電・ヒーター", - "meter": "消費電力", - "pv": "発電" - }, - "otherConsumers": "その他", - "title": "履歴" - }, "loadpoint": { "avgPrice": "⌀ 単価", "charged": "充電量", diff --git a/i18n/lb.json b/i18n/lb.json index 760ef14af7..339ca48d70 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -1230,6 +1230,31 @@ "restartDescription": "Hues du schonns probéiert d'Gerät aus- an erëm unzemaachen?", "secondaryActions": "Nach ëmmer keng Léisung fonnt? Hei sinn e puer weider Méiglechkeeten." }, + "energy": { + "direction": { + "battery": { + "energy": "gelueden", + "returnEnergy": "entlueden" + }, + "grid": { + "energy": "importéiert", + "returnEnergy": "agespeist" + }, + "meter": { + "energy": "Energie", + "returnEnergy": "Energie (ëmgedréint)" + } + }, + "group": { + "battery": "Batterie", + "consumer": "Verbrauch", + "forecast": "Virso", + "grid": "Netz", + "loadpoint": "Lueden & Heizen", + "meter": "Aner Zieler", + "pv": "Produktioun" + } + }, "issue": { "additional": { "copied": "Kopéiert!", @@ -1407,35 +1432,6 @@ "feedIn": "Aspeisung", "title": "Extern Begrenzung aktiv" }, - "history": { - "direction": { - "battery": { - "energy": "gelueden", - "returnEnergy": "entlueden" - }, - "grid": { - "energy": "importéiert", - "returnEnergy": "agespeist" - }, - "meter": { - "energy": "Energie", - "returnEnergy": "Energie (ëmgedréint)" - } - }, - "download": "Eroflueden", - "empty": "Keng historesch Daten fir dësen Zäitraum.", - "group": { - "battery": "Batterie", - "consumer": "Verbrauch", - "forecast": "Virso", - "grid": "Netz", - "loadpoint": "Lueden & Heizen", - "meter": "Aner Zieler", - "pv": "Produktioun" - }, - "otherConsumers": "Aneres", - "title": "Historique" - }, "loadpoint": { "avgPrice": "⌀ Präis", "charged": "Opgelueden", diff --git a/i18n/lt.json b/i18n/lt.json index fe5a6dd951..9d739f166b 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -1230,6 +1230,31 @@ "restartDescription": "Ar pabandėte išjungti ir vėl įjungti?", "secondaryActions": "Vis dar nepavyksta išspręsti problemos? Čia rasite sudėtingesnes opcijas." }, + "energy": { + "direction": { + "battery": { + "energy": "įkrauta", + "returnEnergy": "iškrauta" + }, + "grid": { + "energy": "iš tinklo", + "returnEnergy": "į tinklą" + }, + "meter": { + "energy": "Energija iš tinklo", + "returnEnergy": "Energija (patiekta į tinklą)" + } + }, + "group": { + "battery": "Kaupiklis", + "consumer": "Vartojimas", + "forecast": "Prognozė", + "grid": "Tinklas", + "loadpoint": "Įkrovimas ir Šildymas", + "meter": "Papildomi skaitikliai", + "pv": "Gamyba" + } + }, "issue": { "additional": { "copied": "nukopijuota!", @@ -1407,35 +1432,6 @@ "feedIn": "Atidavimas į tinklą", "title": "Išorinis ribojimas aktyvuotas" }, - "history": { - "direction": { - "battery": { - "energy": "įkrauta", - "returnEnergy": "iškrauta" - }, - "grid": { - "energy": "iš tinklo", - "returnEnergy": "į tinklą" - }, - "meter": { - "energy": "Energija iš tinklo", - "returnEnergy": "Energija (patiekta į tinklą)" - } - }, - "download": "Atsisiųsti", - "empty": "Šio laikotarpio istorijos duomenų nėra.", - "group": { - "battery": "Kaupiklis", - "consumer": "Vartojimas", - "forecast": "Prognozė", - "grid": "Tinklas", - "loadpoint": "Įkrovimas ir Šildymas", - "meter": "Papildomi skaitikliai", - "pv": "Gamyba" - }, - "otherConsumers": "Kiti", - "title": "Istorija" - }, "loadpoint": { "avgPrice": "⌀ Kaina", "charged": "Įkrauta", diff --git a/i18n/lv.json b/i18n/lv.json index a1718ece22..2999975c30 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -1230,6 +1230,31 @@ "restartDescription": "Vai mēģinājāt izslēgt un atkal ieslēgt?", "secondaryActions": "Joprojām nevarat atrisināt problēmu? Šeit ir vēl dažas radikālākas iespējas." }, + "energy": { + "direction": { + "battery": { + "energy": "uzlādēts", + "returnEnergy": "izlādēts" + }, + "grid": { + "energy": "importēts", + "returnEnergy": "eksportēts" + }, + "meter": { + "energy": "Enerģija", + "returnEnergy": "Enerģija (atpakaļvirzienā)" + } + }, + "group": { + "battery": "Akumulators", + "consumer": "Patēriņš", + "forecast": "Prognoze", + "grid": "Tīkls", + "loadpoint": "Uzlāde un sildīšana", + "meter": "Papildu patēriņa skaitītāji", + "pv": "Ražošana" + } + }, "issue": { "additional": { "copied": "nokopēts!", @@ -1407,35 +1432,6 @@ "feedIn": "Atdeve", "title": "Ārējais ierobežojums aktīvs" }, - "history": { - "direction": { - "battery": { - "energy": "uzlādēts", - "returnEnergy": "izlādēts" - }, - "grid": { - "energy": "importēts", - "returnEnergy": "eksportēts" - }, - "meter": { - "energy": "Enerģija", - "returnEnergy": "Enerģija (atpakaļvirzienā)" - } - }, - "download": "Lejupielādēt", - "empty": "Nav vēstures datu par šo laika periodu.", - "group": { - "battery": "Akumulators", - "consumer": "Patēriņš", - "forecast": "Prognoze", - "grid": "Tīkls", - "loadpoint": "Uzlāde un sildīšana", - "meter": "Papildu patēriņa skaitītāji", - "pv": "Ražošana" - }, - "otherConsumers": "Citi", - "title": "Vēsture" - }, "loadpoint": { "avgPrice": "⌀ Cena", "charged": "Uzlādēts", diff --git a/i18n/nl.json b/i18n/nl.json index 18ee320908..a95f1033c7 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1230,6 +1230,31 @@ "restartDescription": "Geprobeerd om het uit en weer aan te zetten?", "secondaryActions": "Nog niet gelukt om je probleem op te lossen? Hier zijn enkele hardhandige opties." }, + "energy": { + "direction": { + "battery": { + "energy": "geladen", + "returnEnergy": "ontladen" + }, + "grid": { + "energy": "geimporteerd", + "returnEnergy": "geexporteerd" + }, + "meter": { + "energy": "Energie", + "returnEnergy": "Energie (omgekeerd)" + } + }, + "group": { + "battery": "Batterij", + "consumer": "Verbruik", + "forecast": "Voorspelling", + "grid": "Net", + "loadpoint": "Laden & Verwarmen", + "meter": "Extra meters", + "pv": "Productie" + } + }, "issue": { "additional": { "copied": "gekopieerd!", @@ -1407,35 +1432,6 @@ "feedIn": "Teruglevering", "title": "Externe limiet actief" }, - "history": { - "direction": { - "battery": { - "energy": "geladen", - "returnEnergy": "ontladen" - }, - "grid": { - "energy": "geimporteerd", - "returnEnergy": "geexporteerd" - }, - "meter": { - "energy": "Energie", - "returnEnergy": "Energie (omgekeerd)" - } - }, - "download": "Download", - "empty": "Er is voor deze tijdspanne geen data beschikbaar.", - "group": { - "battery": "Batterij", - "consumer": "Verbruik", - "forecast": "Voorspelling", - "grid": "Net", - "loadpoint": "Laden & Verwarmen", - "meter": "Extra meters", - "pv": "Productie" - }, - "otherConsumers": "Overig", - "title": "Geschiedenis" - }, "loadpoint": { "avgPrice": "⌀ Prijs", "charged": "Geladen", diff --git a/i18n/pl.json b/i18n/pl.json index 83a8410184..5225e52d53 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -1007,6 +1007,27 @@ "restartDescription": "Próbowałeś go wyłączyć i włączyć ponownie?", "secondaryActions": "Nadal nie możesz rozwiązać swojego problemu? Oto kilka bardziej wymagających opcji." }, + "energy": { + "direction": { + "battery": { + "energy": "naładowane", + "returnEnergy": "rozładowane" + }, + "grid": { + "energy": "zaimportowane", + "returnEnergy": "wyeksportowane" + } + }, + "group": { + "battery": "Magazyn energii", + "consumer": "Konsumpcja", + "forecast": "Prognoza", + "grid": "Sieć", + "loadpoint": "Ładowanie i ogrzewania", + "meter": "Dodatkowe liczniki", + "pv": "Produkcja" + } + }, "issue": { "additional": { "description": "Dołącz konfigurację i logi, aby pomóc nam szybko odtworzyć problem. Zachęcamy do udostępnienia jak największej ilości informacji. Stan zazwyczaj nie jest potrzebny.", @@ -1144,30 +1165,6 @@ "feedIn": "Do sieci", "title": "Limit zewnętrzny aktywny" }, - "history": { - "direction": { - "battery": { - "energy": "naładowane", - "returnEnergy": "rozładowane" - }, - "grid": { - "energy": "zaimportowane", - "returnEnergy": "wyeksportowane" - } - }, - "empty": "Brak dostępnych danych historycznych dla tego przedziału czasu.", - "group": { - "battery": "Magazyn energii", - "consumer": "Konsumpcja", - "forecast": "Prognoza", - "grid": "Sieć", - "loadpoint": "Ładowanie i ogrzewania", - "meter": "Dodatkowe liczniki", - "pv": "Produkcja" - }, - "otherConsumers": "Inne", - "title": "Historia" - }, "loadpoint": { "avgPrice": "⌀ Cena", "charged": "Zużyto", diff --git a/i18n/pt.json b/i18n/pt.json index d7610e6160..12b866706c 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -1175,6 +1175,31 @@ "restartDescription": "Tentou desligar e ligar de novo?", "secondaryActions": "Ainda não conseguiu resolver o seu problema? Aqui estão algumas sugestões mais complexas." }, + "energy": { + "direction": { + "battery": { + "energy": "carregado", + "returnEnergy": "descarregado" + }, + "grid": { + "energy": "importado", + "returnEnergy": "exportado" + }, + "meter": { + "energy": "Energia", + "returnEnergy": "Energia (inversa)" + } + }, + "group": { + "battery": "Bateria", + "consumer": "Consumo", + "forecast": "Previsão", + "grid": "Rede", + "loadpoint": "Carregamento e aquecimento", + "meter": "Contadores adicionais", + "pv": "Produção" + } + }, "issue": { "additional": { "description": "Inclua definições e registos para nos ajudar a reproduzir o problema rapidamente. Recomendamos que partilhe o máximo possível. Geralmente, o estado não é necessário.", @@ -1337,35 +1362,6 @@ "feedIn": "Alimentação", "title": "Limite externo ativo" }, - "history": { - "direction": { - "battery": { - "energy": "carregado", - "returnEnergy": "descarregado" - }, - "grid": { - "energy": "importado", - "returnEnergy": "exportado" - }, - "meter": { - "energy": "Energia", - "returnEnergy": "Energia (inversa)" - } - }, - "download": "Descarregar", - "empty": "Não existem dados históricos disponíveis para este intervalo de tempo.", - "group": { - "battery": "Bateria", - "consumer": "Consumo", - "forecast": "Previsão", - "grid": "Rede", - "loadpoint": "Carregamento e aquecimento", - "meter": "Contadores adicionais", - "pv": "Produção" - }, - "otherConsumers": "Outros", - "title": "Histórico" - }, "loadpoint": { "avgPrice": "Preço ⌀", "charged": "Carregado", diff --git a/i18n/ru.json b/i18n/ru.json index 661aed9547..cddaec805d 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -1191,6 +1191,31 @@ "restartDescription": "Пробовали выключить и снова включить?", "secondaryActions": "Все еще не можете решить свою проблему? Вот несколько более радикальных вариантов." }, + "energy": { + "direction": { + "battery": { + "energy": "заряжено", + "returnEnergy": "разряжено" + }, + "grid": { + "energy": "получено из сети", + "returnEnergy": "отдано в сеть" + }, + "meter": { + "energy": "Энергия", + "returnEnergy": "Энергия (реверсивная)" + } + }, + "group": { + "battery": "Аккумулятор", + "consumer": "Потребление", + "forecast": "Прогноз", + "grid": "Сеть", + "loadpoint": "Зарядки и Нагреватели", + "meter": "Дополнительные счетчики", + "pv": "Генерация" + } + }, "issue": { "additional": { "description": "Включите конфигурацию и журналы, чтобы помочь нам быстро воспроизвести проблему. Мы рекомендуем предоставить как можно больше информации. Состояние обычно не требуется.", @@ -1355,35 +1380,6 @@ "feedIn": "Отдача в сеть", "title": "Внешнее ограничение активно" }, - "history": { - "direction": { - "battery": { - "energy": "заряжено", - "returnEnergy": "разряжено" - }, - "grid": { - "energy": "получено из сети", - "returnEnergy": "отдано в сеть" - }, - "meter": { - "energy": "Энергия", - "returnEnergy": "Энергия (реверсивная)" - } - }, - "download": "Загрузить", - "empty": "Нет данных истории за выбранный период.", - "group": { - "battery": "Аккумулятор", - "consumer": "Потребление", - "forecast": "Прогноз", - "grid": "Сеть", - "loadpoint": "Зарядки и Нагреватели", - "meter": "Дополнительные счетчики", - "pv": "Генерация" - }, - "otherConsumers": "Другие", - "title": "История" - }, "loadpoint": { "avgPrice": "⌀ Цена", "charged": "Заряжено", diff --git a/i18n/sk.json b/i18n/sk.json index 357148b66d..9bec3a264f 100644 --- a/i18n/sk.json +++ b/i18n/sk.json @@ -1230,6 +1230,31 @@ "restartDescription": "Skúsili ste to vypnúť a znova zapnúť?", "secondaryActions": "Stále neviete vyriešiť svoj problém? Tu je niekoľko ďalších, ale náročnejších možností." }, + "energy": { + "direction": { + "battery": { + "energy": "nabité", + "returnEnergy": "vybité" + }, + "grid": { + "energy": "importované", + "returnEnergy": "exportované" + }, + "meter": { + "energy": "Energia", + "returnEnergy": "Energia (spätná)" + } + }, + "group": { + "battery": "Batéria", + "consumer": "Spotreba", + "forecast": "Predpoveď", + "grid": "Sieť", + "loadpoint": "Nabíjanie a vykurovanie", + "meter": "Dodatočné merače", + "pv": "Výroba" + } + }, "issue": { "additional": { "copied": "skopírované!", @@ -1407,35 +1432,6 @@ "feedIn": "Napájanie", "title": "Externý limit aktívny" }, - "history": { - "direction": { - "battery": { - "energy": "nabité", - "returnEnergy": "vybité" - }, - "grid": { - "energy": "importované", - "returnEnergy": "exportované" - }, - "meter": { - "energy": "Energia", - "returnEnergy": "Energia (spätná)" - } - }, - "download": "Stiahnuť", - "empty": "Pre toto časové obdobie nie sú k dispozícii žiadne historické údaje.", - "group": { - "battery": "Batéria", - "consumer": "Spotreba", - "forecast": "Predpoveď", - "grid": "Sieť", - "loadpoint": "Nabíjanie a vykurovanie", - "meter": "Dodatočné merače", - "pv": "Výroba" - }, - "otherConsumers": "Ostatné", - "title": "Priebeh" - }, "loadpoint": { "avgPrice": "⌀ Cena", "charged": "Nabité", diff --git a/i18n/sv.json b/i18n/sv.json index 24788f65b9..b65fadf8a3 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -1230,6 +1230,31 @@ "restartDescription": "Har du testat att starta om?", "secondaryActions": "Lyckas du inte lösa problemet? Här finns mer hjälp." }, + "energy": { + "direction": { + "battery": { + "energy": "laddad", + "returnEnergy": "urladdad" + }, + "grid": { + "energy": "importerad", + "returnEnergy": "exporterad" + }, + "meter": { + "energy": "Energi", + "returnEnergy": "Energi (baklänges)" + } + }, + "group": { + "battery": "Batteri", + "consumer": "Förbrukning", + "forecast": "Prognos", + "grid": "Nät", + "loadpoint": "Laddning & uppvärmning", + "meter": "Extra mätare", + "pv": "Produktion" + } + }, "issue": { "additional": { "copied": "kopierad!", @@ -1407,35 +1432,6 @@ "feedIn": "Inmatning", "title": "Extern begränsning" }, - "history": { - "direction": { - "battery": { - "energy": "laddad", - "returnEnergy": "urladdad" - }, - "grid": { - "energy": "importerad", - "returnEnergy": "exporterad" - }, - "meter": { - "energy": "Energi", - "returnEnergy": "Energi (baklänges)" - } - }, - "download": "Ladda hem", - "empty": "Ingen data för aktuell tidsperiod tillgänglig.", - "group": { - "battery": "Batteri", - "consumer": "Förbrukning", - "forecast": "Prognos", - "grid": "Nät", - "loadpoint": "Laddning & uppvärmning", - "meter": "Extra mätare", - "pv": "Produktion" - }, - "otherConsumers": "Andra", - "title": "Historik" - }, "loadpoint": { "avgPrice": "⌀ pris", "charged": "Laddat", diff --git a/i18n/ta.json b/i18n/ta.json index f10c0de8ae..1c2e1f98d6 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -1064,6 +1064,27 @@ "restartDescription": "அதை மீண்டும் அணைக்க முயற்சித்தீர்களா?", "secondaryActions": "Still not able பெறுநர் solve your problem? Here அரே some more heavy-handed options." }, + "energy": { + "direction": { + "battery": { + "energy": "வசூலிக்கப்பட்டது", + "returnEnergy": "வெளியேற்றப்பட்டது" + }, + "grid": { + "energy": "இறக்குமதி செய்யப்பட்டது", + "returnEnergy": "ஏற்றுமதி செய்யப்பட்டது" + } + }, + "group": { + "battery": "மின்கலம்", + "consumer": "நுகர்வு", + "forecast": "முன்னறிவிப்பு", + "grid": "வலைவாய்", + "loadpoint": "சார்சிங் & ஈட்டிங்", + "meter": "கூடுதல் மீட்டர்", + "pv": "விளைவாக்கம்" + } + }, "issue": { "additional": { "description": "சிக்கலை விரைவாக மீண்டும் உருவாக்க எங்களுக்கு உதவ, உள்ளமைவு மற்றும் பதிவுகளைச் சேர்க்கவும். முடிந்தவரை பகிர்வதை ஊக்குவிக்கிறோம். மாநிலம் பொதுவாக தேவையில்லை.", @@ -1207,30 +1228,6 @@ "feedIn": "ஃபீட்-இன்", "title": "வெளிப்புற வரம்பு செயலில் உள்ளது" }, - "history": { - "direction": { - "battery": { - "energy": "வசூலிக்கப்பட்டது", - "returnEnergy": "வெளியேற்றப்பட்டது" - }, - "grid": { - "energy": "இறக்குமதி செய்யப்பட்டது", - "returnEnergy": "ஏற்றுமதி செய்யப்பட்டது" - } - }, - "empty": "இந்தக் கால வரம்பிற்கு வரலாற்றுத் தரவு எதுவும் இல்லை.", - "group": { - "battery": "மின்கலம்", - "consumer": "நுகர்வு", - "forecast": "முன்னறிவிப்பு", - "grid": "வலைவாய்", - "loadpoint": "சார்சிங் & ஈட்டிங்", - "meter": "கூடுதல் மீட்டர்", - "pv": "விளைவாக்கம்" - }, - "otherConsumers": "மற்றவை", - "title": "வரலாறு" - }, "loadpoint": { "avgPrice": "⌀ விலை", "charged": "மின்சேர்க்கப்பட்டது", diff --git a/i18n/tr.json b/i18n/tr.json index 9446fdc30f..0eab0ac7a9 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -1084,6 +1084,26 @@ "restartDescription": "Cihazı kapatıp tekrar açmayı denedin mi?", "secondaryActions": "Hâlâ bir çözüm bulamadın mı? Burada birkaç seçenek daha var.." }, + "energy": { + "direction": { + "battery": { + "energy": "doldu", + "returnEnergy": "boşaldı" + }, + "grid": { + "energy": "alındı", + "returnEnergy": "satıldı" + } + }, + "group": { + "battery": "Batarya", + "forecast": "Öngörü", + "grid": "Şebeke", + "loadpoint": "Doldurma ve Isıtma", + "meter": "Tüketim", + "pv": "Üretim" + } + }, "issue": { "additional": { "description": "Sorunu hızlı bir şekilde yeniden oluşturmamıza yardımcı olacak yapılandırma ve günlükleri ekle. Mümkün olduğunca fazla bilgi paylaşmanı öneririz. Durum bilgisi genellikle gerekli değildir.", @@ -1224,29 +1244,6 @@ "hemsWarning": { "title": "Harici sınır:" }, - "history": { - "direction": { - "battery": { - "energy": "doldu", - "returnEnergy": "boşaldı" - }, - "grid": { - "energy": "alındı", - "returnEnergy": "satıldı" - } - }, - "empty": "Bu zaman aralığı için geçmiş verisi yok.", - "group": { - "battery": "Batarya", - "forecast": "Öngörü", - "grid": "Şebeke", - "loadpoint": "Doldurma ve Isıtma", - "meter": "Tüketim", - "pv": "Üretim" - }, - "otherConsumers": "Diğerleri", - "title": "Geçmiş" - }, "loadpoint": { "avgPrice": "⌀ Fiyat", "charged": "Doldu", diff --git a/server/http.go b/server/http.go index ff537464c9..a9cf62793f 100644 --- a/server/http.go +++ b/server/http.go @@ -208,6 +208,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler}, "gridsessions": {"GET", "/gridsessions", gridSessionsHandler}, "energyhistory": {"GET", "/history/energy", energyHistoryHandler}, + "energyflow": {"GET", "/history/flow", energyFlowHandler}, + "tariffhistory": {"GET", "/history/tariffs", tariffHistoryHandler}, "optimize": {"POST", "/optimize", callHandler(site.Optimize)}, "telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)}, "devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)}, diff --git a/server/http_history_handler.go b/server/http_history_handler.go index d09de2d516..4cf3c8827b 100644 --- a/server/http_history_handler.go +++ b/server/http_history_handler.go @@ -14,32 +14,42 @@ import ( "golang.org/x/text/language" ) -// energyHistoryHandler returns aggregated energy history data -func energyHistoryHandler(w http.ResponseWriter, r *http.Request) { +// historyRange checks the database and parses the optional RFC3339 `from` and +// `to` query parameters, answering the request itself when either fails +func historyRange(w http.ResponseWriter, r *http.Request) (from, to time.Time, ok bool) { if db.Instance == nil { jsonError(w, http.StatusBadRequest, errors.New("database offline")) - return + return from, to, false } - - q := r.URL.Query() - - var from, to time.Time - - if s := q.Get("from"); s != "" { - var err error - if from, err = time.Parse(time.RFC3339, s); err != nil { - jsonError(w, http.StatusBadRequest, errors.New("invalid 'from' parameter")) - return + for name, dst := range map[string]*time.Time{"from": &from, "to": &to} { + s := r.URL.Query().Get(name) + if s == "" { + continue + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid '%s' parameter", name)) + return from, to, false } + *dst = t } + return from, to, true +} - if s := q.Get("to"); s != "" { - var err error - if to, err = time.Parse(time.RFC3339, s); err != nil { - jsonError(w, http.StatusBadRequest, errors.New("invalid 'to' parameter")) - return - } +// cacheUntilNextSlot marks the response fresh until the next slot boundary, when +// the data may change +func cacheUntilNextSlot(w http.ResponseWriter) { + maxAge := time.Until(time.Now().Truncate(tariff.SlotDuration).Add(tariff.SlotDuration)) + w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d", int(maxAge.Seconds()))) +} + +// energyHistoryHandler returns aggregated energy history data +func energyHistoryHandler(w http.ResponseWriter, r *http.Request) { + from, to, ok := historyRange(w, r) + if !ok { + return } + q := r.URL.Query() aggregate := q.Get("aggregate") if aggregate == "" { @@ -79,9 +89,41 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) { return } - // data only changes at the next slot boundary - maxAge := time.Until(time.Now().Truncate(tariff.SlotDuration).Add(tariff.SlotDuration)) - w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d", int(maxAge.Seconds()))) + cacheUntilNextSlot(w) + + jsonWrite(w, res) +} + +// energyFlowHandler returns the source to sink energy attribution of a period +func energyFlowHandler(w http.ResponseWriter, r *http.Request) { + from, to, ok := historyRange(w, r) + if !ok { + return + } + res, err := metrics.QueryFlow(from, to) + if err != nil { + jsonError(w, http.StatusInternalServerError, err) + return + } + + cacheUntilNextSlot(w) + + jsonWrite(w, res) +} + +// tariffHistoryHandler returns the persisted tariff slots in [from,to) +func tariffHistoryHandler(w http.ResponseWriter, r *http.Request) { + from, to, ok := historyRange(w, r) + if !ok { + return + } + res, err := metrics.QueryTariffs(from, to, r.URL.Query().Get("aggregate")) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + cacheUntilNextSlot(w) jsonWrite(w, res) } diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index aaa7b83bd2..e8b71d07d8 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -2685,24 +2685,10 @@ ], "parameters": [ { - "name": "from", - "in": "query", - "description": "Start time (RFC3339)", - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-07-01T00:00:00Z" - } + "$ref": "#/components/parameters/historyFrom" }, { - "name": "to", - "in": "query", - "description": "End time (RFC3339)", - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-07-02T00:00:00Z" - } + "$ref": "#/components/parameters/historyTo" }, { "name": "aggregate", @@ -2791,7 +2777,79 @@ "content": { "application/json": { "schema": { - "type": "object" + "type": "array", + "description": "One series per entity, or per group when grouped", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Entity title, omitted when grouped" + }, + "group": { + "type": "string", + "enum": [ + "pv", + "battery", + "grid", + "loadpoint", + "consumer", + "meter", + "home", + "forecast", + "temperature" + ] + }, + "isTemp": { + "type": "boolean", + "description": "socTemp values are temperatures, not state of charge" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "energy": { + "type": "number", + "description": "kWh" + }, + "returnEnergy": { + "type": "number", + "description": "kWh exported or discharged" + }, + "socTemp": { + "type": "number", + "description": "State of charge in percent or temperature of a single slot" + }, + "cost": { + "type": "number", + "description": "Grid import cost, where a price exists" + }, + "pricedEnergy": { + "type": "number", + "description": "kWh imported where a price exists" + }, + "returnCost": { + "type": "number", + "description": "Feed-in revenue, where a price exists" + }, + "pricedReturnEnergy": { + "type": "number", + "description": "kWh exported where a price exists" + } + } + } + } + } + } } }, "text/csv": { @@ -2806,6 +2864,208 @@ } } } + }, + "/history/flow": { + "get": { + "operationId": "getEnergyFlow", + "summary": "Energy flow", + "description": "Returns the energy attributed from sources (pv, battery, grid) to sinks (home, battery, loadpoint, export) over the period, plus grid cost and CO2 sums where tariff data exists.", + "tags": [ + "experimental" + ], + "parameters": [ + { + "$ref": "#/components/parameters/historyFrom" + }, + { + "$ref": "#/components/parameters/historyTo" + } + ], + "responses": { + "200": { + "description": "Energy flow data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "flows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "enum": [ + "pv", + "battery", + "grid" + ] + }, + "to": { + "type": "string", + "enum": [ + "home", + "battery", + "loadpoint", + "export" + ] + }, + "energy": { + "type": "number", + "description": "kWh" + }, + "cost": { + "type": "number", + "description": "Cost of the energy, for home and loadpoint" + }, + "pricedEnergy": { + "type": "number", + "description": "kWh where a price exists" + } + } + } + }, + "cost": { + "type": "object", + "description": "Grid cost sums, omitted without grid price data", + "properties": { + "import": { + "type": "number" + }, + "export": { + "type": "number" + }, + "importEnergy": { + "type": "number", + "description": "kWh where a price exists" + }, + "exportEnergy": { + "type": "number", + "description": "kWh where a price exists" + }, + "avgGrid": { + "type": "number" + } + } + }, + "co2": { + "type": "object", + "description": "Grid CO2 sums in kg, omitted without CO2 data", + "properties": { + "consumption": { + "type": "number", + "description": "Emissions of the grid share that went to home and loadpoints" + }, + "consumptionEnergy": { + "type": "number", + "description": "kWh" + }, + "baseline": { + "type": "number" + }, + "avgCo2": { + "type": "number", + "description": "g/kWh" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters or database offline" + } + } + } + }, + "/history/tariffs": { + "get": { + "operationId": "getTariffHistory", + "summary": "Tariff history", + "description": "Returns the persisted 15 minute tariff slots in the period, or their averages per hour, day or month.", + "tags": [ + "experimental" + ], + "parameters": [ + { + "name": "aggregate", + "in": "query", + "description": "Bucket size, 15 minute slots by default", + "schema": { + "type": "string", + "enum": [ + "15m", + "hour", + "day", + "month" + ] + } + }, + { + "$ref": "#/components/parameters/historyFrom" + }, + { + "$ref": "#/components/parameters/historyTo" + } + ], + "responses": { + "200": { + "description": "Tariff slots", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "string", + "format": "date-time" + }, + "grid": { + "type": "number", + "description": "Grid price per kWh, the bucket average when aggregated" + }, + "gridMin": { + "type": "number", + "description": "Lowest slot price in the bucket, aggregated only" + }, + "gridMax": { + "type": "number", + "description": "Highest slot price in the bucket, aggregated only" + }, + "feedin": { + "type": "number", + "description": "Feed-in rate per kWh, the bucket average when aggregated" + }, + "feedinMin": { + "type": "number" + }, + "feedinMax": { + "type": "number" + }, + "co2": { + "type": "number", + "description": "g/kWh" + }, + "temperature": { + "type": "number", + "description": "°C" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters or database offline" + } + } + } } }, "components": { @@ -5640,6 +5900,26 @@ } }, "parameters": { + "historyFrom": { + "name": "from", + "in": "query", + "description": "Start time (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-01T00:00:00Z" + } + }, + "historyTo": { + "name": "to", + "in": "query", + "description": "End time (RFC3339)", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-07-02T00:00:00Z" + } + }, "adminPassword": { "name": "X-Admin-Password", "in": "header", diff --git a/server/openapi.yaml b/server/openapi.yaml index 997fb6ab82..b2db36c7c2 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1634,20 +1634,8 @@ paths: tags: - experimental parameters: - - name: from - in: query - description: Start time (RFC3339) - schema: - type: string - format: date-time - example: "2026-07-01T00:00:00Z" - - name: to - in: query - description: End time (RFC3339) - schema: - type: string - format: date-time - example: "2026-07-02T00:00:00Z" + - $ref: "#/components/parameters/historyFrom" + - $ref: "#/components/parameters/historyTo" - name: aggregate in: query description: "Aggregation interval. Examples: 15m, 1h, day, month" @@ -1711,12 +1699,181 @@ paths: content: application/json: schema: - type: object + type: array + description: One series per entity, or per group when grouped + items: + type: object + properties: + title: + type: string + description: Entity title, omitted when grouped + group: + type: string + enum: [pv, battery, grid, loadpoint, consumer, meter, home, forecast, temperature] + isTemp: + type: boolean + description: socTemp values are temperatures, not state of charge + data: + type: array + items: + type: object + properties: + start: + type: string + format: date-time + end: + type: string + format: date-time + energy: + type: number + description: kWh + returnEnergy: + type: number + description: kWh exported or discharged + socTemp: + type: number + description: State of charge in percent or temperature of a single slot + cost: + type: number + description: Grid import cost, where a price exists + pricedEnergy: + type: number + description: kWh imported where a price exists + returnCost: + type: number + description: Feed-in revenue, where a price exists + pricedReturnEnergy: + type: number + description: kWh exported where a price exists text/csv: schema: type: string "400": description: Invalid parameters or database offline + /history/flow: + get: + operationId: getEnergyFlow + summary: Energy flow + description: "Returns the energy attributed from sources (pv, battery, grid) to sinks (home, battery, loadpoint, export) over the period, plus grid cost and CO2 sums where tariff data exists." + tags: + - experimental + parameters: + - $ref: "#/components/parameters/historyFrom" + - $ref: "#/components/parameters/historyTo" + responses: + "200": + description: Energy flow data + content: + application/json: + schema: + type: object + properties: + flows: + type: array + items: + type: object + properties: + from: + type: string + enum: [pv, battery, grid] + to: + type: string + enum: [home, battery, loadpoint, export] + energy: + type: number + description: kWh + cost: + type: number + description: Cost of the energy, for home and loadpoint + pricedEnergy: + type: number + description: kWh where a price exists + cost: + type: object + description: Grid cost sums, omitted without grid price data + properties: + import: + type: number + export: + type: number + importEnergy: + type: number + description: kWh where a price exists + exportEnergy: + type: number + description: kWh where a price exists + avgGrid: + type: number + co2: + type: object + description: Grid CO2 sums in kg, omitted without CO2 data + properties: + consumption: + type: number + description: Emissions of the grid share that went to home and loadpoints + consumptionEnergy: + type: number + description: kWh + baseline: + type: number + avgCo2: + type: number + description: g/kWh + "400": + description: Invalid parameters or database offline + /history/tariffs: + get: + operationId: getTariffHistory + summary: Tariff history + description: Returns the persisted 15 minute tariff slots in the period, or their averages per hour, day or month. + tags: + - experimental + parameters: + - name: aggregate + in: query + description: Bucket size, 15 minute slots by default + schema: + type: string + enum: [15m, hour, day, month] + - $ref: "#/components/parameters/historyFrom" + - $ref: "#/components/parameters/historyTo" + responses: + "200": + description: Tariff slots + content: + application/json: + schema: + type: array + items: + type: object + properties: + start: + type: string + format: date-time + grid: + type: number + description: Grid price per kWh, the bucket average when aggregated + gridMin: + type: number + description: Lowest slot price in the bucket, aggregated only + gridMax: + type: number + description: Highest slot price in the bucket, aggregated only + feedin: + type: number + description: Feed-in rate per kWh, the bucket average when aggregated + feedinMin: + type: number + feedinMax: + type: number + co2: + type: number + description: g/kWh + temperature: + type: number + description: °C + "400": + description: Invalid parameters or database offline components: schemas: ChangePassword: @@ -1955,6 +2112,22 @@ components: minimum: 0 maximum: 6 parameters: + historyFrom: + name: from + in: query + description: Start time (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-01T00:00:00Z" + historyTo: + name: to + in: query + description: End time (RFC3339) + schema: + type: string + format: date-time + example: "2026-07-02T00:00:00Z" adminPassword: name: X-Admin-Password in: header diff --git a/tests/battery-multi.spec.ts b/tests/battery-multi.spec.ts index 674f27cf8c..07a9852888 100644 --- a/tests/battery-multi.spec.ts +++ b/tests/battery-multi.spec.ts @@ -133,9 +133,9 @@ test.describe("battery page with multiple batteries", async () => { // price range follows the limit: full tariff span at 6 ct, only the // 28 ct zone once the limit excludes the base rate const range = limit.getByTestId("price-range"); - await expect(range).toContainText("6.0 ct – 28.0 ct"); + await expect(range).toContainText("6.0 – 28.0 ct"); await limit.getByRole("combobox").selectOption("0.28"); - await expect(range).toContainText("28.0 ct – 28.0 ct"); + await expect(range).toContainText("28.0 ct"); // switch and limit survive a reload await page.reload(); diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts index 4a64f86f70..733c8ccdcf 100644 --- a/tests/battery-settings.spec.ts +++ b/tests/battery-settings.spec.ts @@ -71,7 +71,7 @@ test.describe("battery settings", async () => { await page.getByLabel("Enable limit").check(); await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" }); await expect(page.getByTestId("active-hours")).toHaveText(["Active time", "96 hr"].join("")); - await expect(page.locator("body")).toContainText("5.0 ct – 50.0 ct"); + await expect(page.locator("body")).toContainText("5.0 – 50.0 ct"); await page.getByRole("link", { name: "Charge" }).click(); await page.getByTestId("energyflow").click(); diff --git a/tests/config-tariffs.spec.ts b/tests/config-tariffs.spec.ts index 69d57feea2..93951111fc 100644 --- a/tests/config-tariffs.spec.ts +++ b/tests/config-tariffs.spec.ts @@ -361,7 +361,7 @@ grid: await save.click(); await expectModalHidden(modal); await expect(tariffGrid).toBeVisible(); - await expect(tariffGrid).toContainText(["Forecast", "10.0 ct – 30.0 ct"].join("")); + await expect(tariffGrid).toContainText(["Forecast", "10.0 – 30.0 ct"].join("")); }); test("charges zones", async ({ page }) => { diff --git a/tests/device-colors.spec.ts b/tests/device-colors.spec.ts index 619e4c5eaf..763df5cb34 100644 --- a/tests/device-colors.spec.ts +++ b/tests/device-colors.spec.ts @@ -28,10 +28,6 @@ async function expectLegendColor(scope: Locator | Page, label: string, rgb: stri await expect(dot).toHaveCSS("background-color", rgb); } -function chartSection(page: Page, heading: string): Locator { - return page.locator("section").filter({ has: page.getByRole("heading", { name: heading }) }); -} - test("device colors: autoassign, override, persistence", async ({ page }) => { // ---------- Step 1 — Sessions, by-vehicle view ---------- await page.goto("/#/sessions?year=2026&month=5"); @@ -57,40 +53,33 @@ test("device colors: autoassign, override, persistence", async ({ page }) => { // non-collision: Carport must shift off BLUE to the first free entry (AMBER) await expectLegendColor(page, "Carport", AMBER); - // ---------- Step 3 — History view, same colors ---------- - await page.goto("/#/history?period=day&year=2026&month=5&day=15"); - const lpSection = page - .locator("section") - .filter({ has: page.getByRole("heading", { name: "Charging & Heating" }) }); - await expect(lpSection).toBeVisible({ timeout: 10000 }); - await expectLegendColor(lpSection, "Garage", BLUE); - await expectLegendColor(lpSection, "Carport", AMBER); + // ---------- Step 3 — Energy page, same colors on the loadpoint cards ---------- + await page.goto("/#/energy?period=day&year=2026&month=5&day=15"); + await expect(page.getByTestId("energy-loadpoint")).toHaveCount(2); + await expectLegendColor(page, "Garage", BLUE); + await expectLegendColor(page, "Carport", AMBER); - // ---------- Step 4 — Custom hex on consumer meter (Consumption chart) ---------- - const consumerSection = chartSection(page, "Consumption"); - await expect(consumerSection).toBeVisible(); - await legendBadge(consumerSection, "Dishwasher").click(); + // ---------- Step 4 — Custom hex on consumer meter (consumers legend) ---------- + await legendBadge(page, "Dishwasher").click(); const popover2 = page.getByRole("dialog"); await expect(popover2).toBeVisible(); // save-on-type fires when input matches hex regex await popover2.getByLabel("Hex color").fill(CUSTOM_HEX); await page.keyboard.press("Escape"); - await expectLegendColor(consumerSection, "Dishwasher", CUSTOM_RGB); + await expectLegendColor(page, "Dishwasher", CUSTOM_RGB); - // ---------- Step 5 — Custom hex on ext meter (Additional meters chart) ---------- - const meterSection = chartSection(page, "Additional meters"); - await expect(meterSection).toBeVisible(); - await legendBadge(meterSection, "Alternative grid").click(); + // ---------- Step 5 — Custom hex on ext meter (Additional meters card) ---------- + await legendBadge(page, "Alternative grid").click(); const popover3 = page.getByRole("dialog"); await expect(popover3).toBeVisible(); await popover3.getByLabel("Hex color").fill(CUSTOM_HEX2); await page.keyboard.press("Escape"); - await expectLegendColor(meterSection, "Alternative grid", CUSTOM_RGB2); + await expectLegendColor(page, "Alternative grid", CUSTOM_RGB2); // ---------- Step 6 — Reload: both colors persist via settings DB ---------- await page.reload(); - await expectLegendColor(chartSection(page, "Consumption"), "Dishwasher", CUSTOM_RGB); - await expectLegendColor(chartSection(page, "Additional meters"), "Alternative grid", CUSTOM_RGB2); + await expectLegendColor(page, "Dishwasher", CUSTOM_RGB); + await expectLegendColor(page, "Alternative grid", CUSTOM_RGB2); }); diff --git a/tests/energy-grid-swap.spec.ts b/tests/energy-grid-swap.spec.ts new file mode 100644 index 0000000000..b58d01f6a8 --- /dev/null +++ b/tests/energy-grid-swap.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +// 2026-04-11: two grid entities from a swapped device ref, old "grid" with +// data 11:00-11:45, leftover "db:2" 12:00-12:45 +test.beforeAll(async () => { + await start("config-grid-only.evcc.yaml", "energy-history.sql"); +}); +test.afterAll(async () => { + await stop(); +}); + +test("both grid entities count as one grid", async ({ page }) => { + await page.goto("/#/energy?period=day&year=2026&month=4&day=11"); + + // 8 slots of 0.5 / 0.1 kWh across both entities + const grid = page.getByTestId("energy-grid"); + await expect(grid).toContainText("Import"); + await expect(grid).toContainText("4.0 kWh"); + // no tariff, no prices in the chart, the tiles point at the tariff settings + await expect(grid).not.toContainText("Import price"); + await expect(page.getByTestId("energy-stat-gridImport")).toContainText("No price data."); + await expect(page.getByTestId("energy-stat-gridExport")).toContainText("Configure now"); + + // the slot of the leftover entity reads like any other slot + const chart = grid.getByTestId("group-chart-grid"); + const box = await chart.boundingBox(); + if (!box) throw new Error("chart not visible"); + // plot area spans the chart width minus the 36px axis on the right + await chart.hover({ position: { x: ((box.width - 36) * 48.5) / 96, y: box.height / 2 } }); + // the tooltip renders outside the card + await expect(page.getByRole("table")).toContainText("12:00 – 12:15"); + await expect(page.getByRole("table")).toContainText("2.0 kW"); +}); diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts index ec93772cb2..fb1dccc97e 100644 --- a/tests/energy-history.spec.ts +++ b/tests/energy-history.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page, type Locator } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -72,21 +73,28 @@ test.describe("api", () => { }); 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(); + await page.goto(`/#/energy?period=day&year=${year}&month=${month}&day=${day}`); + await expect(page.getByTestId("energy-flow")).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(); + await page.goto(`/#/energy?period=month&year=${year}&month=${month}`); + await expect(page.getByTestId("energy-flow")).toBeVisible(); } -function chart(page: Page, group: string): Locator { - return page.getByTestId(`group-chart-${group}`); +const CARDS: Record = { + pv: "energy-production", + battery: "energy-battery", + consumer: "energy-consumers", + meter: "energy-meters", +}; + +function card(page: Page, group: string): Locator { + return page.getByTestId(CARDS[group] ?? ""); } -function section(page: Page, group: string): Locator { - return page.getByTestId(`history-section-${group}`); +function chart(page: Page, group: string): Locator { + return card(page, group).getByTestId(`group-chart-${group}`).first(); } // Y-axis labels rendered by echarts: position=right uses text-anchor=start. @@ -98,21 +106,6 @@ async function yAxis(c: Locator): Promise { } 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([ @@ -130,30 +123,12 @@ test.describe("axis and units", () => { expect(await yAxis(chart(page, "battery"))).toEqual(["kW", "-6", "-3", "0", "3", "6"]); }); - test("stacked batteries: axis follows stacked sum, not per-entity max", async ({ page }) => { - await gotoDay(page, 2026, 4, 8); - expect(await yAxis(chart(page, "battery"))).toEqual([ - "kW", - "-3.0", - "-1.5", - "0.0", - "1.5", - "3.0", - ]); - }); - 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. @@ -176,92 +151,139 @@ test.describe("axis and units", () => { 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 }) => { + test("home total, tiles, virtual Others", async ({ page }) => { await gotoDay(page, 2026, 4, 7); - const consumption = section(page, "consumer"); + const consumption = card(page, "consumer"); await expect(consumption).toBeVisible(); - // Section total = home, not sum of meters. + // Card total = home, not sum of meters. await expect(consumption.getByRole("heading")).toContainText("1.0 kWh"); - // Others (virtual) + explicit meters. - await expect(consumption.getByRole("button", { name: "Others 300 Wh" })).toBeVisible(); - await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" })).toBeVisible(); - await expect(consumption.getByRole("button", { name: "Office 300 Wh" })).toBeVisible(); + // Others (virtual) + explicit meters, as tiles and legend rows. + for (const name of ["Others 300 Wh", "Kitchen 400 Wh", "Office 300 Wh"]) { + await expect(consumption.getByRole("button", { name })).toHaveCount(2); + } + // the breakdown comes first, a timeline only opens for a selected consumer + await expect(consumption.getByRole("heading", { name: "Consumers" })).toBeVisible(); + await expect(chart(page, "consumer")).toHaveCount(0); + }); + + test("treemap or river, remembered", async ({ page }) => { + await gotoDay(page, 2026, 4, 7); + const consumption = card(page, "consumer"); + await expect(consumption.getByTestId("consumer-treemap")).toContainText("Kitchen"); + await expect(consumption.getByTestId("consumer-river")).toHaveCount(0); + + // the stream replaces the tiles, the legend stays + await consumption.getByTitle("River").click(); + await expect(consumption.getByTestId("consumer-river")).toBeVisible(); + await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" })).toHaveCount(1); + + await page.reload(); + await expect(consumption.getByTestId("consumer-river")).toBeVisible(); + + // switching the view drops the selection + await consumption.getByRole("button", { name: "Kitchen 400 Wh" }).click(); + await expect(page.getByTestId("consumer-detail")).toBeVisible(); + await consumption.getByTitle("Treemap").click(); + await expect(page.getByTestId("consumer-detail")).toHaveCount(0); + await expect(consumption.getByTestId("consumer-river")).toHaveCount(0); + await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" })).toHaveCount(2); }); // 2026-03-24: home = 0.4 kWh, no meter entities with data. - test("home without meters shows chart without legend", async ({ page }) => { + test("home without meters shows the consumption alone", async ({ page }) => { await gotoDay(page, 2026, 3, 24); - const consumption = section(page, "consumer"); - await expect(consumption).toBeVisible(); - - await expect(consumption.getByRole("heading")).toContainText("0.4 kWh"); - // No explicit consumers, no entity legend. - await expect(consumption.getByRole("button")).toHaveCount(0); + const consumption = card(page, "consumer"); + await expect(consumption.getByRole("heading", { name: "Consumption 400 Wh" })).toBeVisible(); + await expect(chart(page, "consumer")).toBeVisible(); + // nothing to break down: no tiles, no legend, not closable + await expect(consumption.getByRole("heading", { name: "Consumers" })).toHaveCount(0); + await expect(consumption.getByRole("button", { name: "Others" })).toHaveCount(0); + await expect(consumption.getByRole("button", { name: "Close" })).toHaveCount(0); + await expect(consumption.getByTitle("River")).toHaveCount(0); }); // 2026-04-12: consumers are not a bidirectional group. Return energy is // ignored, not netted or split: home 1.0, Kitchen 0.4. test("return energy is ignored", async ({ page }) => { await gotoDay(page, 2026, 4, 12); - const consumption = section(page, "consumer"); - await expect(consumption).toBeVisible(); - + const consumption = card(page, "consumer"); await expect(consumption.getByRole("heading")).toContainText("1.0 kWh"); - await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" })).toBeVisible(); - await expect(consumption.getByRole("button", { name: "Others 600 Wh" })).toBeVisible(); + await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" }).first()).toBeVisible(); + await expect(consumption.getByRole("button", { name: "Others 600 Wh" }).first()).toBeVisible(); }); - test("entity focus rescales axis and resets on unfocus", async ({ page }) => { + test("tile opens the detail with its own axis, second click closes", async ({ page }) => { await gotoDay(page, 2026, 4, 7); - const consumption = section(page, "consumer"); - const meterChart = chart(page, "consumer"); + const consumption = card(page, "consumer"); + const kitchen = consumption.getByRole("button", { name: "Kitchen 400 Wh" }).first(); + await kitchen.click(); - // 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"]); + // Kitchen alone: peak 400 W → unit switches to W, floored at 1000 W. + const detail = page.getByTestId("consumer-detail"); + await expect(detail).toBeVisible(); + await expect + .poll(() => yAxis(chart(page, "consumer"))) + .toEqual(["W", "0", "250", "500", "750", "1,000"]); + + // the house ran on solar that hour, the consumer inherits its sources + const sources = detail.getByRole("button", { name: "Sources" }); + await expect(sources).toContainText("100% solar"); + await sources.click(); + const modal = page.getByTestId("energy-sources-modal"); + await expectModalVisible(modal); + await expect(modal.getByRole("heading", { name: "Kitchen" })).toBeVisible(); + await expect(modal.getByRole("row", { name: "Solar" })).toContainText("400 Wh"); + await modal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(modal); - // 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"]); + await expect(detail).toHaveCount(0); + }); - // 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"]); + test("river layer opens its consumer", async ({ page }) => { + await gotoDay(page, 2026, 4, 7); + const consumption = card(page, "consumer"); + await consumption.getByTitle("River").click(); + const river = consumption.getByTestId("consumer-river"); + const box = await river.boundingBox(); + if (!box) throw new Error("chart not visible"); + // 12:22, the stream is symmetric around its middle: Others below, Kitchen in + // the middle, Office on top + await river.click({ + position: { x: (box.width * (12 * 60 + 22)) / 1440, y: 8 + (box.height - 32) / 2 }, + }); + await expect( + page.getByTestId("consumer-detail").getByRole("heading", { name: "Kitchen" }) + ).toBeVisible(); }); }); -test.describe("battery legend", () => { +test.describe("battery card", () => { // 2026-07: Battery 6.0/3.0 kWh, Battery 2 balanced at 4.5/4.5 kWh. A net sum // would collapse the balanced battery to zero, so both directions are shown. - test("month legend keeps charge and discharge apart", async ({ page }) => { + test("month keeps charge and discharge apart per battery", async ({ page }) => { await gotoMonth(page, 2026, 7); - const battery = section(page, "battery"); - await expect(battery).toBeVisible(); - - await expect(battery.getByRole("heading")).toContainText( - "10.5 kWh charged · 7.5 kWh discharged" - ); - await expect(battery.getByRole("button", { name: "Battery 6.0 kWh · 3.0 kWh" })).toBeVisible(); - await expect( - battery.getByRole("button", { name: "Battery 2 4.5 kWh · 4.5 kWh" }) - ).toBeVisible(); + // one card per battery + const cards = page.getByTestId("energy-battery"); + await expect(cards).toHaveCount(2); + await expect(cards.first().getByRole("heading", { name: "Battery" })).toBeVisible(); + await expect(cards.first()).toContainText("6.0 kWh"); + await expect(cards.first()).toContainText("3.0 kWh"); + await expect(cards.last().getByRole("heading", { name: "Battery 2" })).toBeVisible(); + await expect(cards.last()).toContainText("4.5 kWh"); }); }); test.describe("additional meters", () => { // 2026-04-09: single ext meter "Submeter" = 1.2 kWh, no home data. - test("standalone section without virtual Others", async ({ page }) => { + test("standalone card without virtual Others", async ({ page }) => { await gotoDay(page, 2026, 4, 9); - const additional = section(page, "meter"); + const additional = card(page, "meter"); await expect(additional).toBeVisible(); - - // No section total: additional meters can be import, export, or consumption. - await expect(additional.getByRole("heading")).not.toContainText("kWh"); - - // Explicit entity legend, no virtual "Others" (unlike the consumer group). - await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible(); + await expect(additional).toContainText("Submeter"); + await expect(additional).toContainText("1.2 kWh"); await expect(additional.getByText("Others", { exact: true })).toBeHidden(); }); @@ -271,34 +293,27 @@ test.describe("additional meters", () => { expect(await yAxis(chart(page, "meter"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]); }); - // 2026-04-10: "Submeter" 2.0/0.4 kWh. A netted value would read the same - // for import and export, so any return energy shows both directions. + // 2026-04-10: "Submeter" 2.0/0.4 kWh, both directions as stats. test("bidirectional meter shows both directions", async ({ page }) => { await gotoDay(page, 2026, 4, 10); - const additional = section(page, "meter"); - await expect( - additional.getByRole("button", { name: "Submeter 2.0 kWh · 400 Wh" }) - ).toBeVisible(); + const additional = card(page, "meter"); + await expect(additional).toContainText("Energy2.0 kWh"); + await expect(additional).toContainText("Energy (reverse)400 Wh"); }); - // 2026-04-13: export-only meter without an importing sibling. Return energy - // alone triggers the split, otherwise 1.2 kWh would read as consumption. - test("export-only meter alone shows both directions", async ({ page }) => { + // 2026-04-13: export-only meter. Return energy alone shows the reverse stat. + test("export-only meter shows the reverse direction", async ({ page }) => { await gotoDay(page, 2026, 4, 13); - const additional = section(page, "meter"); - await expect( - additional.getByRole("button", { name: "Feed-in meter 0.0 kWh · 1.2 kWh" }) - ).toBeVisible(); + const additional = card(page, "meter"); + await expect(additional).toContainText("Feed-in meter"); + await expect(additional).toContainText("Energy (reverse)1.2 kWh"); }); }); test.describe("reconnect", () => { - test("history still shows content after backend restart", async ({ page }) => { + test("page still shows content after backend restart", async ({ page }) => { await gotoDay(page, 2026, 3, 24); - await expect(chart(page, "grid")).toBeVisible(); - await restart(); - - await expect(chart(page, "grid")).toBeVisible(); + await expect(page.getByTestId("energy-flow")).toBeVisible(); }); }); diff --git a/tests/energy-history.sql b/tests/energy-history.sql index cdcc072947..9e837452ff 100644 --- a/tests/energy-history.sql +++ b/tests/energy-history.sql @@ -113,6 +113,11 @@ 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); +-- solar covers the house that hour, so the consumers have a source +INSERT INTO `meters` VALUES (4, 1775556000, 0.25, 0); +INSERT INTO `meters` VALUES (4, 1775556900, 0.25, 0); +INSERT INTO `meters` VALUES (4, 1775557800, 0.25, 0); +INSERT INTO `meters` VALUES (4, 1775558700, 0.25, 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 diff --git a/tests/energy.spec.ts b/tests/energy.spec.ts new file mode 100644 index 0000000000..9561889b5d --- /dev/null +++ b/tests/energy.spec.ts @@ -0,0 +1,302 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +const from = "2026-09-15T00:00:00+02:00"; +const to = "2026-09-16T00:00:00+02:00"; + +test.beforeAll(async () => { + await start(undefined, "energy.sql"); +}); +test.afterAll(async () => { + await stop(); +}); + +test.describe("api", () => { + test("flows and costs", async ({ request }) => { + const res = await request.get(`${baseUrl()}/api/history/flow`, { params: { from, to } }); + expect(res.ok()).toBeTruthy(); + + const data = await res.json(); + // what reached home and loadpoint is priced: self-produced at feed-in 0.10, grid at ø 0.30 + const priced = (cost: number, pricedEnergy: number) => ({ + cost: expect.closeTo(cost, 6), + pricedEnergy, + }); + expect(data.flows).toEqual([ + { from: "pv", to: "home", energy: 1, ...priced(0.1, 1) }, + { from: "pv", to: "battery", energy: 1 }, + { from: "pv", to: "loadpoint", energy: 1, ...priced(0.1, 1) }, + { from: "pv", to: "export", energy: 1 }, + { from: "battery", to: "home", energy: 1, ...priced(0.1, 1) }, + { from: "grid", to: "loadpoint", energy: 2, ...priced(0.6, 2) }, + ]); + // import 2 kWh × 0.30, export 1 kWh × 0.10 + expect(data.cost.import).toBeCloseTo(0.6, 6); + expect(data.cost.export).toBeCloseTo(0.1, 6); + expect(data.cost.avgGrid).toBeCloseTo(0.3, 6); + expect(data.co2.baseline).toBeCloseTo(2, 6); + }); + + test("tariffs", async ({ request }) => { + const res = await request.get("/api/history/tariffs", { + params: { from: "2026-09-15T00:00:00+02:00", to: "2026-09-16T00:00:00+02:00" }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + expect(data).toHaveLength(4); + expect(data[3].grid).toBeCloseTo(0.35, 6); + expect(data[3].feedin).toBeCloseTo(0.1, 6); + }); + + test("empty period", async ({ request }) => { + const res = await request.get(`${baseUrl()}/api/history/flow`, { + params: { from: "2026-01-01T00:00:00+01:00", to: "2026-01-02T00:00:00+01:00" }, + }); + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + expect(data.flows ?? []).toHaveLength(0); + expect(data.cost).toBeUndefined(); + }); +}); + +test.describe("page", () => { + test("flow chart and stats", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=9&day=15"); + const card = page.getByTestId("energy-flow"); + await expect(card).toBeVisible(); + + // stacked areas with legend by default, usage and sankey behind the toggle + await expect(card.getByText("Grid export")).toBeVisible(); + await card.getByTitle("Usage").click(); + await expect(card.getByText("Consumption")).toBeVisible(); + await expect(card.getByText("Carport")).toBeVisible(); + await expect(card.getByText("Battery")).toBeVisible(); + await card.getByTitle("Energy flow").click(); + const chart = card.getByTestId("flow-chart"); + await expect(chart.getByText("Production")).toBeVisible(); + await expect(chart.getByText("Consumption")).toBeVisible(); + await expect(chart.getByText("Charging")).toBeVisible(); + await expect(chart.getByText("Grid export")).toBeVisible(); + + // autarky 1 - 2/5, grid import 2 kWh and export 1 kWh + await expect(page.getByTestId("energy-stat-autarky")).toContainText("60%"); + const grid = page.getByTestId("energy-grid"); + await expect(grid).toContainText("Import"); + await expect(grid).toContainText("2.0 kWh"); + await expect(grid).toContainText("Export"); + await expect(grid).toContainText("1.0 kWh"); + // priced: cost and revenue with the average price below + const gridImport = page.getByTestId("energy-stat-gridImport"); + await expect(gridImport).toContainText("Grid cost"); + await expect(gridImport).toContainText("0.60 €"); + await expect(gridImport).toContainText("ø 30.0 ct/kWh"); + await expect(page.getByTestId("energy-stat-gridExport")).toContainText("0.10 €"); + // every defined price is in the legend, the import price moves between 25 and 35 ct + await expect(grid).toContainText("Import price"); + await expect(grid).toContainText("25.0 – 35.0 ct/kWh"); + await expect(grid).toContainText("Export price"); + await expect(grid).toContainText("10.0 ct/kWh"); + + // pv 4 kWh vs forecast 3.2 kWh, self-consumption 1 - 1/4 + const production = page.getByTestId("energy-production"); + await expect(production).toContainText("4.0 kWh"); + await expect(production.getByText("Forecast", { exact: true })).toBeVisible(); + const selfConsumed = page.getByTestId("energy-stat-selfConsumed"); + await expect(selfConsumed).toContainText("Self-consumed"); + await expect(selfConsumed).toContainText("75%"); + await expect(selfConsumed).toContainText("25% exported"); + const forecast = page.getByTestId("energy-stat-forecast"); + await expect(forecast).toContainText("Forecast accuracy"); + await expect(forecast).toContainText("75%"); + await expect(forecast).toContainText("800 Wh below"); + + // battery 1 kWh charged, 1 kWh discharged + const battery = page.getByTestId("energy-battery"); + await expect(battery).toContainText("Charged"); + await expect(battery).toContainText("Discharged"); + + // loadpoint card with its 3 kWh + const loadpoint = page.getByTestId("energy-loadpoint"); + await expect(loadpoint.getByRole("heading", { name: "Carport 3.0 kWh" })).toBeVisible(); + + // additional meter outside the balance + const meters = page.getByTestId("energy-meters"); + await expect(meters).toContainText("Pool"); + await expect(meters).toContainText("300 Wh"); + + // fixture has no consumer meters, the house consumption stands alone + await expect( + page.getByTestId("energy-consumers").getByRole("heading", { name: "Consumption 2.0 kWh" }) + ).toBeVisible(); + + // savings 1.50 - 0.90 + await expect(page.getByTestId("energy-stat-savings")).toContainText("0.60 €"); + // co2 saved 2 kg - 0.8 kg + await expect(page.getByTestId("energy-stat-co2")).toContainText("1 kg"); + }); + + // plot area spans the chart width minus the 36px axes, 96 slots a day + const slotX = (width: number, slot: number, left = 0) => + left + ((width - left - 36) * (slot + 0.5)) / 96; + + test("production tooltip with forecast", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=9&day=15"); + // 12:00: 2 kWh produced, 1.6 kWh forecast, as average power + const chart = page.getByTestId("energy-production").getByTestId("group-chart-pv"); + const box = await chart.boundingBox(); + if (!box) throw new Error("chart not visible"); + await chart.hover({ position: { x: slotX(box.width, 48), y: box.height / 2 } }); + const tooltip = page.getByRole("table"); + await expect(tooltip).toContainText("12:00 – 12:15"); + await expect(tooltip.getByRole("row", { name: "Production" })).toContainText("8.0 kW"); + await expect(tooltip.getByRole("row", { name: "Forecast" })).toContainText("6.4 kW"); + }); + + test("battery soc below the bars", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=9&day=15"); + // 12:00: charging 0.5 kWh at 50%, the soc axis takes 36px on the left + const chart = page.getByTestId("energy-battery").getByTestId("group-chart-battery"); + const box = await chart.boundingBox(); + if (!box) throw new Error("chart not visible"); + const x = slotX(box.width, 48, 36); + const tooltip = page.getByRole("table"); + // the bars and the soc panel share one tooltip + for (const y of [box.height / 3, box.height - 40]) { + await chart.hover({ position: { x, y } }); + await expect(tooltip.getByRole("row", { name: "charged 2.0 kW", exact: true })).toBeVisible(); + await expect(tooltip.getByRole("row", { name: "charge 50%", exact: true })).toBeVisible(); + await page.mouse.move(0, 0); + } + }); + + test("grid tooltip with cost and effective price", async ({ page }) => { + await page.goto("/#/energy?period=month&year=2026&month=9"); + // 15th: 2 kWh imported at 0.25 and 0.35, 1 kWh exported at 0.10 + const chart = page.getByTestId("energy-grid").getByTestId("group-chart-grid"); + const box = await chart.boundingBox(); + if (!box) throw new Error("chart not visible"); + // plot area spans the chart width minus the 36px axis on the right + await chart.hover({ position: { x: ((box.width - 36) * 14.5) / 30, y: box.height / 2 } }); + const tooltip = page.getByRole("table"); + const amount = tooltip.getByRole("row", { name: "Amount" }); + await expect(amount).toContainText("0.60 €"); + await expect(amount).toContainText("0.10 €"); + const price = tooltip.getByRole("row", { name: "Price" }); + await expect(price).toContainText("30.0 ct/kWh"); + await expect(price).toContainText("10.0 ct/kWh"); + }); + + test("flow chart loadpoint label", async ({ page }) => { + const card = page.getByTestId("energy-flow"); + const chart = card.getByTestId("flow-chart"); + + // charger only + await page.goto("/#/energy?year=2026&month=9&day=15"); + await card.getByTitle("Energy flow").click(); + await expect(chart.getByText("Charging")).toBeVisible(); + await expect(chart.getByText("Heating")).toHaveCount(0); + + // heater only + await page.goto("/#/energy?year=2026&month=9&day=16"); + await expect(chart.getByText("Heating")).toBeVisible(); + await expect(chart.getByText("Charging")).toHaveCount(0); + + // both + await page.goto("/#/energy?period=month&year=2026&month=9"); + await expect(chart.getByText("Charging &")).toBeVisible(); + await expect(chart.getByText("Heating")).toBeVisible(); + }); + + test("bottom tab with experimental, sessions moves to more on phones", async ({ page }) => { + await page.goto("/#/config"); + const experimentalEntry = page.getByTestId("generalconfig-experimental"); + await experimentalEntry.getByRole("button", { name: "edit" }).click(); + const experimentalModal = page.getByTestId("experimental-modal"); + await expectModalVisible(experimentalModal); + await experimentalModal.getByLabel("Enable experimental features.").click(); + await experimentalModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(experimentalModal); + + const bar = page.getByTestId("bottom-tab-bar"); + await expect(bar.getByRole("link", { name: "Sessions" })).toBeVisible(); + await bar.getByRole("link", { name: "Energy" }).click(); + await expect(page.getByRole("heading", { name: "Energy" })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 800 }); + await expect(bar.getByRole("link", { name: "Sessions" })).toBeHidden(); + await bar.getByTestId("tab-more").click(); + await expect(bar.getByTestId("tab-more").getByRole("link", { name: "Sessions" })).toBeVisible(); + }); + + test.describe("sources", () => { + test("shares and cost", async ({ page }) => { + // carport 3 kWh: 1 solar at feed-in 0.10, 2 grid at ø 0.30 + await page.goto("/#/energy?year=2026&month=9&day=15"); + const sources = page.getByTestId("energy-loadpoint").getByRole("button", { name: "Sources" }); + await expect(sources).toContainText("33% solar"); + await expect(sources).toContainText("0% battery, 67% grid"); + + await sources.click(); + const modal = page.getByTestId("energy-sources-modal"); + await expectModalVisible(modal); + await expect(modal.getByRole("heading", { name: "Carport" })).toBeVisible(); + await expect(modal).toContainText("Sep 15, 2026"); + const solar = modal.getByRole("row", { name: "Solar" }); + await expect(solar).toContainText("33%"); + await expect(solar).toContainText("1.0 kWh"); + await expect(solar).toContainText("0.10 €"); + await expect(solar).toContainText("10.0 ct"); + const grid = modal.getByRole("row", { name: "Grid" }); + await expect(grid).toContainText("67%"); + await expect(grid).toContainText("2.0 kWh"); + await expect(grid).toContainText("0.60 €"); + await expect(grid).toContainText("30.0 ct"); + const total = modal.getByRole("row", { name: "Total" }); + await expect(total).toContainText("3.0 kWh"); + await expect(total).toContainText("0.70 €"); + await expect(modal.getByText("valued at feed-in price")).toBeVisible(); + }); + + test("no price data", async ({ page }) => { + // solar only, neither battery nor grid has data that day + await page.goto("/#/energy?year=2026&month=9&day=17"); + const loadpoint = page.getByTestId("energy-loadpoint"); + await expect(loadpoint.getByRole("heading", { name: "Carport 1.0 kWh" })).toBeVisible(); + const sources = loadpoint.getByRole("button", { name: "Sources" }); + await expect(sources).toContainText("100% solar"); + await expect(sources).not.toContainText("battery"); + await expect(sources).not.toContainText("grid"); + + await sources.click(); + const modal = page.getByTestId("energy-sources-modal"); + await expectModalVisible(modal); + await expect(modal.getByRole("row", { name: "Solar" })).toContainText("1.0 kWh"); + await expect(modal.getByRole("row", { name: "Battery" })).toHaveCount(0); + await expect(modal.getByRole("row", { name: "Grid" })).toHaveCount(0); + await expect(modal.getByRole("columnheader", { name: "Cost" })).toHaveCount(0); + await expect(modal.getByText("valued at feed-in price")).toHaveCount(0); + }); + + test("battery leads without solar", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=9&day=18"); + const sources = page.getByTestId("energy-loadpoint").getByRole("button", { name: "Sources" }); + await expect(sources).toContainText("100% battery"); + await expect(sources).not.toContainText("solar"); + }); + + test("grid only", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=9&day=16"); + const loadpoint = page.getByTestId("energy-loadpoint"); + await expect(loadpoint.getByRole("heading", { name: "Heat pump 1.0 kWh" })).toBeVisible(); + await expect(loadpoint.getByRole("button", { name: "Sources" })).toHaveCount(0); + }); + }); + + test("empty state", async ({ page }) => { + await page.goto("/#/energy?year=2026&month=1&day=1"); + await expect(page.getByText("No energy data available")).toBeVisible(); + }); +}); diff --git a/tests/energy.sql b/tests/energy.sql new file mode 100644 index 0000000000..e0b44e10a4 --- /dev/null +++ b/tests/energy.sql @@ -0,0 +1,87 @@ +CREATE TABLE `entities` ( + `id` integer, + `group` text, + `name` text, + `title` text, + `is_temp` numeric, + PRIMARY KEY (`id`) +); +CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`); + +CREATE TABLE `meters` ( + `meter` integer, + `ts` integer, + `energy` real, + `return_energy` real +); +CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`); + +CREATE TABLE `tariffs` ( + `ts` integer, + `grid` real, + `feedin` real, + `co2` real, + `temperature` real +); +CREATE UNIQUE INDEX `idx_tariffs_timestamp` ON `tariffs`(`ts`); + +INSERT INTO `entities` (id, `group`, name, title) VALUES (1, 'home', 'home', 'home'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (2, 'grid', 'grid', 'grid'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (3, 'battery', 'battery', 'Battery'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (4, 'pv', 'solar', 'Solar'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (5, 'loadpoint', 'carport', 'Carport'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (6, 'forecast', 'forecast', 'forecast'); +INSERT INTO `entities` (id, `group`, name, title) VALUES (7, 'meter', 'pool', 'Pool'); +INSERT INTO `entities` (id, `group`, name, title, is_temp) VALUES (8, 'loadpoint', 'heatpump', 'Heat pump', 1); + +-- 2026-09-15 12:00 and 12:15 CEST (+02:00): sunny, export and battery charging +-- pv 2 → home 0.5, battery 0.5, loadpoint 0.5, export 0.5 +INSERT INTO `meters` VALUES (4, 1789466400, 2, 0); +INSERT INTO `meters` VALUES (7, 1789466400, 0.3, 0); +INSERT INTO `meters` VALUES (6, 1789466400, 1.6, 0); +INSERT INTO `meters` VALUES (1, 1789466400, 0.5, 0); +INSERT INTO `meters` VALUES (3, 1789466400, 0.5, 0); +INSERT INTO `meters` VALUES (5, 1789466400, 0.5, 0); +INSERT INTO `meters` VALUES (2, 1789466400, 0, 0.5); +INSERT INTO `meters` VALUES (4, 1789467300, 2, 0); +INSERT INTO `meters` VALUES (6, 1789467300, 1.6, 0); +INSERT INTO `meters` VALUES (1, 1789467300, 0.5, 0); +INSERT INTO `meters` VALUES (3, 1789467300, 0.5, 0); +INSERT INTO `meters` VALUES (5, 1789467300, 0.5, 0); +INSERT INTO `meters` VALUES (2, 1789467300, 0, 0.5); + +-- 2026-09-15 22:00 and 22:15 CEST: night, battery discharge and grid import +-- battery 0.5 → home 0.5, grid 1 → loadpoint 1 +INSERT INTO `meters` VALUES (3, 1789502400, 0, 0.5); +INSERT INTO `meters` VALUES (1, 1789502400, 0.5, 0); +INSERT INTO `meters` VALUES (5, 1789502400, 1, 0); +INSERT INTO `meters` VALUES (2, 1789502400, 1, 0); +INSERT INTO `meters` VALUES (3, 1789503300, 0, 0.5); +INSERT INTO `meters` VALUES (1, 1789503300, 0.5, 0); +INSERT INTO `meters` VALUES (5, 1789503300, 1, 0); +INSERT INTO `meters` VALUES (2, 1789503300, 1, 0); + +-- 2026-09-16 12:00 CEST: heating only, grid 1 → heat pump 1 +INSERT INTO `meters` VALUES (8, 1789552800, 1, 0); +INSERT INTO `meters` VALUES (2, 1789552800, 1, 0); + +-- 2026-09-17 12:00 CEST: solar charging without price data, pv 1 → carport 1 +INSERT INTO `meters` VALUES (4, 1789639200, 1, 0); +INSERT INTO `meters` VALUES (5, 1789639200, 1, 0); + +-- 2026-09-18 12:00 CEST: no solar, battery 1 → carport 1 +INSERT INTO `meters` VALUES (3, 1789725600, 0, 1); +INSERT INTO `meters` VALUES (5, 1789725600, 1, 0); + +-- tariffs for all four slots: grid 0.30 (night 0.25 / 0.35), feed-in 0.10, co2 400 g/kWh +INSERT INTO `tariffs` VALUES (1789466400, 0.3, 0.1, 400, NULL); +INSERT INTO `tariffs` VALUES (1789467300, 0.3, 0.1, 400, NULL); +INSERT INTO `tariffs` VALUES (1789502400, 0.25, 0.1, 400, NULL); +INSERT INTO `tariffs` VALUES (1789503300, 0.35, 0.1, 400, NULL); + +-- battery soc on 2026-09-15: charging at noon, discharging at night +ALTER TABLE `meters` ADD COLUMN `soc_temp` real; +UPDATE `meters` SET soc_temp = 50 WHERE meter = 3 AND ts = 1789466400; +UPDATE `meters` SET soc_temp = 60 WHERE meter = 3 AND ts = 1789467300; +UPDATE `meters` SET soc_temp = 70 WHERE meter = 3 AND ts = 1789502400; +UPDATE `meters` SET soc_temp = 60 WHERE meter = 3 AND ts = 1789503300; diff --git a/tests/history-grid-swap.spec.ts b/tests/history-grid-swap.spec.ts deleted file mode 100644 index 62430f647c..0000000000 --- a/tests/history-grid-swap.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, baseUrl } from "./evcc"; - -test.use({ baseURL: baseUrl() }); - -// 2026-04-11: two grid entities from a swapped device ref, old "grid" with -// data 11:00-11:45, leftover "db:2" 12:00-12:45 -test.beforeAll(async () => { - await start("config-grid-only.evcc.yaml", "energy-history.sql"); -}); -test.afterAll(async () => { - await stop(); -}); - -test("tooltip shows one merged grid entity", async ({ page }) => { - await page.goto("/#/history?period=day&year=2026&month=4&day=11"); - - const chart = page.getByTestId("group-chart-grid"); - await expect(chart.locator("svg")).toBeVisible(); - - // hover the center of the 12:00-12:15 slot (48 of 96, plot area spans - // the chart width minus the 36px right margin) - const box = await chart.boundingBox(); - if (!box) throw new Error("grid chart not visible"); - await chart.hover({ position: { x: ((box.width - 36) * 48.5) / 96, y: box.height / 2 } }); - - const tooltip = chart.locator("table"); - await expect(tooltip).toBeVisible(); - // single unnamed row, no per-entity rows; one unit across both columns - await expect(tooltip).toHaveText( - ["12:00 – 12:15", "imported", "exported", "2.0 kW", "0.4 kW"].join("") - ); -}); From 6e8cd7d657f9282388365019a927d213a4a7cffc Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 5 Oct 2026 10:38:37 +0200 Subject: [PATCH 29/29] Backport command: create the release branch with the deploy token (#34428) --- .github/workflows/command-backport.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml index 7f5aa67495..90db3bef20 100644 --- a/.github/workflows/command-backport.yml +++ b/.github/workflows/command-backport.yml @@ -48,6 +48,9 @@ jobs: id: pr uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: + # creating the release branch is subject to the release/** ruleset, which + # GITHUB_TOKEN cannot bypass + github-token: ${{ secrets.RELEASE_DEPLOY_TOKEN }} script: | const { owner, repo } = context.repo;