Collect 15min energy metrics (#23185)

This commit is contained in:
andig 2026-04-15 13:34:28 +02:00 • committed by GitHub
parent b6fda32128
commit 81b78db887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1375 additions and 266 deletions

View file

@ -55,7 +55,10 @@
to="/optimize"
active-class="active"
>
Optimize
Optimize 🧪
</router-link>
<router-link v-if="experimental" class="dropdown-item" to="/history" active-class="active">
History 🧪
</router-link>
</div>
</template>

View file

@ -0,0 +1,177 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Line ref="chartRef" :data="chartData" :options="chartOptions" :height="300" />
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
LinearScale,
TimeScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip,
type ChartData,
type ChartOptions,
} from "chart.js";
import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import { Line } from "vue-chartjs";
import colors, { dimColor } from "@/colors";
import { commonOptions } from "../Sessions/chartConfig";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import formatter from "@/mixins/formatter";
import { is12hFormat } from "@/units";
ChartJS.register(
LinearScale,
TimeScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip
);
interface Slot {
start: string;
end: string;
import: number;
export: number;
}
export interface SeriesData {
name: string;
data: Slot[];
}
function slotPower(slot: Slot, field: "import" | "export"): number {
const hours = (new Date(slot.end).getTime() - new Date(slot.start).getTime()) / 3_600_000;
return hours > 0 ? slot[field] / hours : 0;
}
export default defineComponent({
name: "HistoryChart",
components: { Line, LegendList },
mixins: [formatter],
props: {
series: { type: Array as PropType<SeriesData[]>, default: () => [] },
from: { type: Date, required: true },
to: { type: Date, required: true },
},
computed: {
chartData(): ChartData<"line"> {
const datasets = this.series.map((s, i) => {
const color = colors.palette[i % colors.palette.length];
const fill = dimColor(color);
const points = s.data.map((slot) => ({
x: new Date(slot.start).getTime(),
y: slotPower(slot, "import") - slotPower(slot, "export"),
}));
return {
label: s.name,
data: points,
borderColor: color,
backgroundColor: fill,
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.3,
};
});
return { datasets } as ChartData<"line">;
},
chartOptions(): ChartOptions<"line"> {
const locale = this.$i18n?.locale;
const fmtTime = new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
hour12: is12hFormat(),
});
const fmtDayShort = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
});
return {
...commonOptions,
scales: {
x: {
type: "time",
min: this.from.getTime(),
max: this.to.getTime(),
border: { display: false },
grid: { display: false },
ticks: {
maxTicksLimit: 12,
color: colors.muted || undefined,
autoSkip: true,
maxRotation: 0,
callback: (value: number | string) => {
const d = new Date(value);
if (d.getHours() === 0 && d.getMinutes() === 0) {
return [fmtTime.format(d), fmtDayShort.format(d)];
}
return fmtTime.format(d);
},
},
},
y: {
border: { display: false },
suggestedMin: 0,
title: {
display: true,
text: "kW",
color: colors.muted || undefined,
},
ticks: {
color: colors.muted || undefined,
callback: (value: number | string) => Number(value).toFixed(1),
},
grid: {
color: (ctx: { tick: { value: number } }) =>
ctx.tick.value === 0
? colors.muted || undefined
: colors.border || undefined,
},
},
},
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
callbacks: {
title: (items) => {
const val = items[0]?.parsed?.x ?? 0;
if (!val) return "";
const d = new Date(val);
return `${fmtDayShort.format(d)} ${fmtTime.format(d)}`;
},
label: (ctx) => {
const val = (ctx.parsed.y ?? 0).toFixed(1);
return `${ctx.dataset.label}: ${val} kW`;
},
},
},
},
} as ChartOptions<"line">;
},
legends(): Legend[] {
return this.series.map((s, i) => ({
label: s.name,
color: colors.palette[i % colors.palette.length],
value: "",
}));
},
},
});
</script>

View file

@ -0,0 +1,170 @@
<template>
<div class="mb-5">
<div class="chart-container my-3">
<Bar ref="chartRef" :data="chartData" :options="chartOptions" :height="300" />
</div>
<LegendList :legends="legends" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
BarElement,
Tooltip,
type ChartData,
type ChartOptions,
} from "chart.js";
import { Bar } from "vue-chartjs";
import colors, { lighterColor } from "@/colors";
import { commonOptions } from "../Sessions/chartConfig";
import LegendList from "../Sessions/LegendList.vue";
import type { Legend } from "../Sessions/types";
import formatter from "@/mixins/formatter";
import type { SeriesData } from "./Chart.vue";
ChartJS.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip);
export default defineComponent({
name: "HistoryEnergyChart",
components: { Bar, LegendList },
mixins: [formatter],
props: {
series: { type: Array as PropType<SeriesData[]>, default: () => [] },
from: { type: Date, required: true },
days: { type: Number, default: 14 },
},
computed: {
dayDates(): Date[] {
const result: Date[] = [];
for (let i = 0; i < this.days; i++) {
const d = new Date(this.from);
d.setDate(d.getDate() + i);
result.push(d);
}
return result;
},
labels(): string[] {
const locale = this.$i18n?.locale;
const fmt = new Intl.DateTimeFormat(locale, {
weekday: "short",
day: "numeric",
month: "short",
});
return this.dayDates.map((d) => fmt.format(d));
},
chartData(): ChartData<"bar"> {
const datasets: ChartData<"bar">["datasets"] = [];
const dayKeys = this.dayDates.map(
(d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
);
this.series.forEach((s, i) => {
const color = colors.palette[i % colors.palette.length];
// index data by day
const byDay: Record<string, { import: number; export: number }> = {};
s.data.forEach((slot) => {
const d = new Date(slot.start);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
byDay[key] = { import: slot.import, export: slot.export };
});
const importData = dayKeys.map((key) => byDay[key]?.import ?? 0);
const exportData = dayKeys.map((key) => -(byDay[key]?.export ?? 0));
const hasExport = exportData.some((v) => v !== 0);
const importLabel = hasExport ? `${s.name} (import)` : s.name;
datasets.push({
label: importLabel,
data: importData,
backgroundColor: color,
stack: `s${i}`,
});
if (hasExport) {
datasets.push({
label: `${s.name} (export)`,
data: exportData,
backgroundColor: lighterColor(color),
stack: `s${i}`,
});
}
});
return { labels: this.labels, datasets };
},
chartOptions(): ChartOptions<"bar"> {
return {
...commonOptions,
scales: {
x: {
border: { display: false },
grid: { display: false },
ticks: {
color: colors.muted || undefined,
maxRotation: 0,
},
},
y: {
border: { display: false },
title: {
display: true,
text: "kWh",
color: colors.muted || undefined,
},
ticks: {
color: colors.muted || undefined,
callback: (value: number | string) => Number(value).toFixed(1),
},
grid: {
color: (ctx: { tick: { value: number } }) =>
ctx.tick.value === 0
? colors.muted || undefined
: colors.border || undefined,
},
},
},
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
callbacks: {
label: (ctx) => {
const val = ctx.parsed.y ?? 0;
const abs = Math.abs(val);
const label = ctx.dataset.label || "";
return `${label}: ${abs.toFixed(1)} kWh`;
},
},
},
},
} as ChartOptions<"bar">;
},
legends(): Legend[] {
const result: Legend[] = [];
this.series.forEach((s, i) => {
const color = colors.palette[i % colors.palette.length];
const hasExport = s.data.some((slot) => slot.export > 0);
const importLabel = hasExport ? `${s.name} (import)` : s.name;
result.push({ label: importLabel, color, value: "" });
if (hasExport) {
result.push({
label: `${s.name} (export)`,
color: lighterColor(color),
value: "",
});
}
});
return result;
},
},
});
</script>

View file

@ -89,6 +89,11 @@
Optimize 🧪
</router-link>
</li>
<li v-if="experimental">
<router-link class="dropdown-item" to="/history" active-class="active">
History 🧪
</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<template v-if="authorizationRequired">
<li>

View file

@ -120,6 +120,11 @@ export default function setupRouter(i18n: VueI18nInstance) {
component: () => import("./views/Battery.vue"),
props: true,
},
{
path: "/history",
component: () => import("./views/History.vue"),
props: true,
},
{
path: "/optimize",
component: () => import("./views/Optimize.vue"),

103
assets/js/views/History.vue Normal file
View file

@ -0,0 +1,103 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader title="History" />
<div class="row">
<main class="col-12">
<div v-if="loading" class="my-5 text-center text-muted">loading...</div>
<template v-else>
<section v-if="powerSeries.length" class="mb-5">
<h3 class="fw-normal mb-3">Power <small class="ms-2">48 hours</small></h3>
<PowerChart :series="powerSeries" :from="powerFrom" :to="powerTo" />
</section>
<section v-if="energySeries.length" class="mb-5">
<h3 class="fw-normal mb-3">Energy <small class="ms-2">14 days</small></h3>
<EnergyChart :series="energySeries" :from="energyFrom" :days="14" />
</section>
<div
v-if="!powerSeries.length && !energySeries.length"
class="my-5 text-center text-muted"
>
no data
</div>
</template>
</main>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import PowerChart from "../components/History/Chart.vue";
import EnergyChart from "../components/History/EnergyChart.vue";
import api from "../api";
export default defineComponent({
name: "History",
components: {
TopHeader: Header,
PowerChart,
EnergyChart,
},
data() {
return {
powerSeries: [] as any[],
energySeries: [] as any[],
powerFrom: new Date(),
powerTo: new Date(),
energyFrom: new Date(),
loading: true,
interval: null as ReturnType<typeof setInterval> | null,
};
},
head() {
return { title: "History" };
},
mounted() {
this.fetchData();
this.interval = setInterval(() => this.fetchData(), 15 * 60 * 1e3);
},
unmounted() {
if (this.interval) {
clearInterval(this.interval);
}
},
methods: {
async fetchData() {
try {
this.powerTo = new Date();
this.powerFrom = new Date();
this.powerFrom.setDate(this.powerFrom.getDate() - 2);
this.powerFrom.setHours(0, 0, 0, 0);
this.energyFrom = new Date();
this.energyFrom.setDate(this.energyFrom.getDate() - 13);
this.energyFrom.setHours(0, 0, 0, 0);
const [powerRes, energyRes] = await Promise.all([
api.get("history/energy", {
params: {
from: this.powerFrom.toISOString(),
to: this.powerTo.toISOString(),
},
}),
api.get("history/energy", {
params: {
from: this.energyFrom.toISOString(),
to: this.powerTo.toISOString(),
aggregate: "day",
},
}),
]);
this.powerSeries = powerRes.data || [];
this.energySeries = energyRes.data || [];
} catch (e) {
console.error("Failed to load energy history", e);
} finally {
this.loading = false;
}
},
},
});
</script>

View file

@ -1,60 +0,0 @@
package core
import (
"bytes"
"fmt"
"time"
"github.com/benbjohnson/clock"
)
type meterEnergy struct {
clock clock.Clock
updated time.Time
meter *float64 // kWh
Accumulated float64 `json:"accumulated"` // kWh
}
func (m *meterEnergy) String() string {
b := new(bytes.Buffer)
fmt.Fprintf(b, "Accumulated: %.3fkWh updated: %v", m.Accumulated, m.updated.Truncate(time.Second))
if m.meter != nil {
fmt.Fprintf(b, " meter: %.3fkWh", *m.meter)
}
return b.String()
}
// AccumulatedEnergy returns the accumulated energy in kWh
func (m *meterEnergy) AccumulatedEnergy() float64 {
return m.Accumulated
}
// AddMeterTotal adds the difference to the last total meter value in kWh
func (m *meterEnergy) AddMeterTotal(v float64) {
defer func() {
m.updated = m.clock.Now()
m.meter = new(v)
}()
if m.meter == nil {
return
}
m.Accumulated += v - *m.meter
}
// AddEnergy adds the given energy in kWh
func (m *meterEnergy) AddEnergy(v float64) {
defer func() { m.updated = m.clock.Now() }()
if m.updated.IsZero() {
return
}
m.Accumulated += v
}
// AddPower adds the given power in W, calculating the energy based on the time since the last update
func (m *meterEnergy) AddPower(v float64) {
m.AddEnergy(v * m.clock.Since(m.updated).Hours() / 1e3)
}

126
core/metrics/accumulator.go Normal file
View file

@ -0,0 +1,126 @@
package metrics
import (
"bytes"
"fmt"
"time"
"github.com/benbjohnson/clock"
"github.com/samber/lo"
)
type Accumulator struct {
clock clock.Clock
updated time.Time
importMeter *float64 // kWh
exportMeter *float64 // kWh
Import float64 `json:"import"` // kWh
Export float64 `json:"export"` // kWh
}
func WithClock(clock clock.Clock) func(*Accumulator) {
return func(m *Accumulator) {
m.clock = clock
}
}
func NewAccumulator(opt ...func(*Accumulator)) *Accumulator {
m := &Accumulator{clock: clock.New()}
for _, o := range opt {
o(m)
}
return m
}
func (m *Accumulator) Updated() time.Time {
return m.updated
}
func (m *Accumulator) String() string {
b := new(bytes.Buffer)
fmt.Fprintf(b, "Accumulated: %.3fkWh pos, %.3fkWh neg, updated: %v", m.Import, m.Export, m.updated.Truncate(time.Second))
if m.importMeter != nil || m.exportMeter != nil {
fmt.Fprintf(b, " meter: ")
if m.importMeter != nil {
fmt.Fprintf(b, " %.3fkWh pos", *m.importMeter)
}
if m.exportMeter != nil {
fmt.Fprintf(b, " %.3fkWh pos", *m.exportMeter)
}
}
return b.String()
}
// Imported returns the accumulated import energy in kWh
func (m *Accumulator) Imported() float64 {
return m.Import
}
// Exported returns the accumulated export energy in kWh
func (m *Accumulator) Exported() float64 {
return m.Export
}
// SetImportMeterTotal adds the difference to the last total meter value in kWh
func (m *Accumulator) SetImportMeterTotal(v float64) {
defer func() {
m.updated = m.clock.Now()
m.importMeter = lo.ToPtr(v)
}()
if m.importMeter == nil {
return
}
if v >= *m.importMeter {
m.Import += v - *m.importMeter
}
}
// SetExportMeterTotal adds the difference to the last total meter value in kWh
func (m *Accumulator) SetExportMeterTotal(v float64) {
defer func() {
m.updated = m.clock.Now()
m.exportMeter = lo.ToPtr(v)
}()
if m.exportMeter == nil {
return
}
if v >= *m.exportMeter {
m.Export += v - *m.exportMeter
}
}
// AddImportEnergy adds the given energy in kWh to the positive meter
func (m *Accumulator) AddImportEnergy(v float64) {
defer func() { m.updated = m.clock.Now() }()
if m.updated.IsZero() {
return
}
m.Import += v
}
// AddExportEnergy adds the given energy in kWh to the negative meter
func (m *Accumulator) AddExportEnergy(v float64) {
defer func() { m.updated = m.clock.Now() }()
if m.updated.IsZero() {
return
}
m.Export += v
}
// AddPower adds the given power in W, calculating the energy based on the time since the last update
func (m *Accumulator) AddPower(v float64) {
since := v * m.clock.Since(m.updated).Hours() / 1e3
if v >= 0 {
m.AddImportEnergy(since)
} else {
m.AddExportEnergy(-since)
}
}

View file

@ -1,4 +1,4 @@
package core
package metrics
import (
"testing"
@ -13,31 +13,31 @@ func TestMeterEnergyMeterTotal(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
me := &meterEnergy{clock: clock}
me := &Accumulator{clock: clock}
me.AddMeterTotal(10)
assert.Equal(t, 0.0, me.AccumulatedEnergy())
me.AddMeterTotal(11)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
me.AddMeterTotal(11)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
me.SetImportMeterTotal(10)
assert.Equal(t, 0.0, me.Imported())
me.SetImportMeterTotal(11)
assert.Equal(t, 1.0, me.Imported())
me.SetImportMeterTotal(11)
assert.Equal(t, 1.0, me.Imported())
}
func TestMeterEnergyAddPower(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
me := &meterEnergy{clock: clock}
me := &Accumulator{clock: clock}
clock.Add(60 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 0.0, me.AccumulatedEnergy())
assert.Equal(t, 0.0, me.Imported())
clock.Add(60 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 1.0, me.AccumulatedEnergy())
assert.Equal(t, 1.0, me.Imported())
clock.Add(30 * time.Minute)
me.AddPower(1e3)
assert.Equal(t, 1.5, me.AccumulatedEnergy())
assert.Equal(t, 1.5, me.Imported())
}

126
core/metrics/collector.go Normal file
View file

@ -0,0 +1,126 @@
package metrics
import (
"time"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/tariff"
)
const (
// groups
Grid = "grid"
PV = "pv"
Home = "home" // meter and group (virtual measurement)
)
type Collector struct {
entity entity
accu *Accumulator
started time.Time
}
func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) {
entity, err := createEntity(group, name)
if err != nil {
return nil, err
}
c := &Collector{
entity: entity,
accu: NewAccumulator(opt...),
}
return c, nil
}
func createEntity(group, name string) (entity, error) {
entity := entity{
Group: group,
Name: name,
}
if err := db.Instance.Where(&entity).FirstOrCreate(&entity).Error; err != nil {
return entity, err
}
return entity, nil
}
func (c *Collector) process(fun func()) error {
now := c.accu.clock.Now()
fun()
if slotStart := now.Truncate(tariff.SlotDuration); slotStart.After(c.started) {
// skip incomplete first slot
if !c.started.IsZero() {
if err := c.persist(); err != nil {
return err
}
}
c.started = slotStart
c.accu.Import = 0
c.accu.Export = 0
}
return nil
}
func (c *Collector) persist() error {
return persist(c.entity, c.started, c.accu.Imported(), c.accu.Exported())
}
func (c *Collector) ImportProfile(from time.Time) (*[96]float64, error) {
return importProfile(c.entity, from)
}
func (c *Collector) AddImportEnergy(v float64) error {
return c.process(func() {
c.accu.AddImportEnergy(v)
})
}
func (c *Collector) AddExportEnergy(v float64) error {
return c.process(func() {
c.accu.AddExportEnergy(v)
})
}
func (c *Collector) SetImportMeterTotal(v float64) error {
return c.process(func() {
c.accu.SetImportMeterTotal(v)
})
}
func (c *Collector) SetExportMeterTotal(v float64) error {
return c.process(func() {
c.accu.SetExportMeterTotal(v)
})
}
// AddEnergy adds energy using meter totals if available, falling back to power integration.
func (c *Collector) AddEnergy(importTotal, exportTotal *float64, power float64) error {
return c.process(func() {
switch {
case importTotal != nil && exportTotal != nil:
c.accu.SetImportMeterTotal(*importTotal)
c.accu.SetExportMeterTotal(*exportTotal)
case importTotal != nil:
// export via power integration (before meter updates clock)
if power < 0 {
c.accu.AddPower(power)
}
c.accu.SetImportMeterTotal(*importTotal)
case exportTotal != nil:
// import via power integration (before meter updates clock)
if power >= 0 {
c.accu.AddPower(power)
}
c.accu.SetExportMeterTotal(*exportTotal)
default:
c.accu.AddPower(power)
}
})
}

View file

@ -0,0 +1,132 @@
package metrics
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
func TestCollectorAddEnergy(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
col, err := NewCollector("foo", "foo", WithClock(clock))
require.NoError(t, err)
require.True(t, col.accu.updated.IsZero())
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(nil, nil, 1e3))
require.False(t, col.accu.updated.IsZero())
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(nil, nil, 1e3))
require.Equal(t, 1e3*5/60/1e3, col.accu.Imported()) // kWh
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(nil, nil, 1e3))
require.Equal(t, 0.0, col.accu.Imported()) // accumulator reset after 15 minutes
clock.Add(15 * time.Minute)
require.NoError(t, col.AddEnergy(nil, nil, 1e3))
require.Equal(t, 0.0, col.accu.Imported()) // accumulator reset after 15 minutes
}
func f(v float64) *float64 { return &v }
func TestCollectorAddEnergyWithImportMeter(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
col, err := NewCollector("bar", "bar", WithClock(clock))
require.NoError(t, err)
// first call: seeds meter, no delta yet
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(f(50000), nil, 1e3))
require.Equal(t, 0.0, col.accu.Imported())
// second call: meter delta of 0.5 kWh, power ignored for import
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(f(50000.5), nil, 1e3))
require.Equal(t, 0.5, col.accu.Imported())
// implausible reading (decreased): ignored by guard
clock.Add(5 * time.Minute)
require.NoError(t, col.AddEnergy(f(49000), nil, 1e3))
require.Equal(t, 0.0, col.accu.Imported()) // reset at slot boundary
}
func TestCollectorAddEnergyWithImportMeterAndExport(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
col, err := NewCollector("baz", "baz", WithClock(clock))
require.NoError(t, err)
// seed import meter
clock.Add(3 * time.Minute)
require.NoError(t, col.AddEnergy(f(1000), nil, 0))
// positive power: import via meter delta, no export
clock.Add(3 * time.Minute)
require.NoError(t, col.AddEnergy(f(1000.3), nil, 500))
require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
require.Equal(t, 0.0, col.accu.Exported())
// negative power: import via meter (no change), export via power integration
clock.Add(3 * time.Minute)
require.NoError(t, col.AddEnergy(f(1000.3), nil, -600))
require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
require.InDelta(t, 600.0*3/60/1e3, col.accu.Exported(), 1e-10) // 0.03 kWh
}
func TestCollectorAddEnergyWithBothMeters(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
col, err := NewCollector("qux", "qux", WithClock(clock))
require.NoError(t, err)
// seed both meters
clock.Add(3 * time.Minute)
require.NoError(t, col.AddEnergy(f(1000), f(2000), 0))
// both deltas used, power ignored
clock.Add(3 * time.Minute)
require.NoError(t, col.AddEnergy(f(1000.3), f(2000.7), 999))
require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
require.InDelta(t, 0.7, col.accu.Exported(), 1e-10)
}
func TestCollectorSetImportAndExportMeterTotal(t *testing.T) {
clock := clock.NewMock()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
col, err := NewCollector("set", "set", WithClock(clock))
require.NoError(t, err)
// seed both import and export
clock.Add(5 * time.Minute)
require.NoError(t, col.SetImportMeterTotal(1000))
require.NoError(t, col.SetExportMeterTotal(2000))
// both deltas used
clock.Add(5 * time.Minute)
require.NoError(t, col.SetImportMeterTotal(1000.3))
require.NoError(t, col.SetExportMeterTotal(2000.7))
require.InDelta(t, 0.3, col.accu.Imported(), 1e-10)
require.InDelta(t, 0.7, col.accu.Exported(), 1e-10)
}

View file

@ -10,42 +10,226 @@ import (
)
type meter struct {
Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meter_ts"`
Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meter_ts"`
Value float64 `json:"val" gorm:"column:val"`
Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meters_meter_ts"`
Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meters_meter_ts"` // start of 15min slot
Entity entity `json:"-" gorm:"foreignkey:Meter;references:Id"`
Import float64 `json:"import" gorm:"column:import"`
Export float64 `json:"export" gorm:"column:export"`
}
type entity struct {
Id int `gorm:"column:id;primarykey"`
Group string `gorm:"column:group;uniqueIndex:entities_group_name"`
Name string `gorm:"column:name;uniqueIndex:entities_group_name"`
}
var ErrIncomplete = errors.New("meter profile incomplete")
// Slot represents an aggregated energy time slot
type Slot struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Import float64 `json:"import"`
Export float64 `json:"export"`
}
// Series represents a named series of energy slots
type Series struct {
Name string `json:"name"`
Data []Slot `json:"data"`
}
var aggregateFormats = map[string]string{
"15m": "%Y-%m-%d %H:%M",
"hour": "%Y-%m-%d %H:00",
"day": "%Y-%m-%d",
"month": "%Y-%m",
}
var aggregateGoFormats = map[string]string{
"15m": "2006-01-02 15:04",
"hour": "2006-01-02 15:00",
"day": "2006-01-02",
"month": "2006-01",
}
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) },
"day": func(t time.Time) time.Time { return t.AddDate(0, 0, 1) },
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
}
// QueryImportEnergy returns aggregated import energy data from the meters table
func QueryImportEnergy(from, to time.Time, aggregate string) ([]Series, error) {
format, ok := aggregateFormats[aggregate]
if !ok {
return nil, errors.New("invalid aggregate value")
}
addDuration := aggregateDurations[aggregate]
// use Go's tz offset instead of SQLite's 'localtime'
tz := time.Now().Format("-07:00")
tx := db.Instance.Table("meters m").
Select(`e.name AS label, strftime('` + format + `', m.ts, '` + tz + `') AS bucket,
COALESCE(SUM(m."import"), 0) AS import, COALESCE(SUM(m.export), 0) AS export`).
Joins("JOIN entities e ON m.meter = e.id").
Group("label, bucket").
Order("label, bucket")
if !from.IsZero() {
tx = tx.Where("m.ts >= ?", from)
}
if !to.IsZero() {
tx = tx.Where("m.ts < ?", to)
}
rows, err := tx.Rows()
if err != nil {
return nil, err
}
defer rows.Close()
seriesMap := make(map[string][]Slot)
var order []string
for rows.Next() {
var label, bucket string
var imp, exp float64
if err := rows.Scan(&label, &bucket, &imp, &exp); err != nil {
return nil, err
}
start, err := time.ParseInLocation(aggregateGoFormats[aggregate], bucket, time.Now().Location())
if err != nil {
return nil, err
}
if _, exists := seriesMap[label]; !exists {
order = append(order, label)
}
seriesMap[label] = append(seriesMap[label], Slot{
Start: start,
End: addDuration(start),
Import: imp,
Export: exp,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
res := make([]Series, 0, len(order))
for _, name := range order {
res = append(res, Series{
Name: name,
Data: seriesMap[name],
})
}
return res, nil
}
func init() {
db.Register(func(db *gorm.DB) error {
return db.AutoMigrate(new(meter))
db.Register(func(_ *gorm.DB) error {
return SetupSchema()
})
}
// Persist stores 15min consumption in Wh
func Persist(ts time.Time, value float64) error {
return db.Instance.Create(meter{
Meter: 1,
Timestamp: ts.Truncate(15 * time.Minute),
Value: value,
// SetupSchema is used for testing
func SetupSchema() error {
m := db.Instance.Migrator()
// entites: create entity first to make sure foreign keys for existing data work
hasTable := m.HasTable(new(entity))
if err := db.Instance.AutoMigrate(new(entity)); err != nil {
return err
}
// entites: add entity id 1
if hasTable {
var res entity
if err := db.Instance.Where(&entity{Id: 1, Name: Home}).FirstOrCreate(&res).Error; err != nil {
return err
}
if res.Group == "" {
res.Group = Home
if err := db.Instance.Save(&res).Error; err != nil {
return err
}
}
}
// drop obsolete indexes
for _, idx := range []struct {
name string
obj any
}{
{"name_idx", new(entity)},
{"group_name", new(entity)},
{"meter_ts", new(meter)},
} {
if m.HasIndex(idx.obj, idx.name) {
if err := m.DropIndex(idx.obj, idx.name); err != nil {
return err
}
}
}
// meter: split energy direction
if old := "val"; m.HasColumn(new(meter), old) {
if err := m.RenameColumn(new(meter), old, "import"); err != nil {
return err
}
}
// meter: split energy direction #2
if old := "pos"; m.HasColumn(new(meter), old) {
if err := m.RenameColumn(new(meter), old, "import"); err != nil {
return err
}
}
if old := "neg"; m.HasColumn(new(meter), old) {
if err := m.RenameColumn(new(meter), old, "export"); err != nil {
return err
}
}
return db.Instance.AutoMigrate(new(meter))
}
// persist stores 15min consumption in kWh
func persist(entity entity, ts time.Time, imp, exp float64) error {
return db.Instance.Create(&meter{
Meter: entity.Id,
Timestamp: ts.Truncate(tariff.SlotDuration),
Import: imp,
Export: exp,
}).Error
}
// Profile returns a 15min average meter profile in Wh.
// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
func Profile(from time.Time) (*[96]float64, error) {
// importProfile returns a 15min average meter profile in Wh. The profile
// is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values.
func importProfile(entity entity, from time.Time) (*[96]float64, error) {
db, err := db.Instance.DB()
if err != nil {
return nil, err
}
// Use 'localtime' in strftime to fix https://github.com/evcc-io/evcc/discussions/23759
rows, err := db.Query(`SELECT min(ts) AS ts, avg(val) AS val
// use Go's tz offset instead of SQLite's 'localtime'
tz := from.Format("-07:00")
rows, err := db.Query(`SELECT min(ts) AS ts, avg(import) AS import
FROM meters
WHERE meter = ? AND ts >= ?
GROUP BY strftime("%H:%M", ts, 'localtime')
ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, 1, from,
GROUP BY strftime("%H:%M", ts, '`+tz+`')
ORDER BY strftime("%H:%M", ts, '`+tz+`') ASC`, entity.Id, from,
)
if err != nil {
return nil, err

102
core/metrics/db_test.go Normal file
View file

@ -0,0 +1,102 @@
package metrics
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/server/db"
"github.com/stretchr/testify/require"
)
func TestSqliteTimestamp(t *testing.T) {
clock := clock.NewMock()
clock.Add(time.Hour)
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
entity := entity{Name: "foo"}
require.NoError(t, db.Instance.FirstOrCreate(&entity).Error)
persist(entity, clock.Now(), 0, 0)
db, err := db.Instance.DB()
require.NoError(t, err)
var (
ts SqlTime
val float64
)
for _, sql := range []string{
`SELECT ts, import FROM meters`,
`SELECT min(ts), import FROM meters`,
`SELECT unixepoch(ts), import FROM meters`,
`SELECT unixepoch(min(ts)), import FROM meters`,
`SELECT min(ts) AS ts, avg(import) AS import
FROM meters
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`,
} {
require.NoError(t, db.QueryRow(sql).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
require.NoError(t, db.QueryRow(`SELECT ts, import FROM meters WHERE ts >= ?`, clock.Now()).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
func TestUpdateProfile(t *testing.T) {
clock := clock.NewMock()
// adjust for 00:00 in local timezone
_, o := clock.Now().Zone()
clock.Add(-time.Duration(o) * time.Second)
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
entity := entity{Id: 2, Name: "foo"}
require.NoError(t, db.Instance.FirstOrCreate(&entity).Error)
// 2 days of data
// day 1: 0 ... 95
// day 2: 96 ... 181
for i := range 4 * 2 * 24 {
persist(entity, clock.Now(), float64(i), float64(i))
clock.Add(15 * time.Minute)
}
{
from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
prof, err := importProfile(entity, from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
if i < 48 {
expected[i] = float64(48+i+144+i) / 2
continue
}
expected[i] = float64(96 - 48 + i)
}
require.Equal(t, expected, *prof, "partial profile: expected %v, got %v", expected, *prof)
}
{
from := clock.Now().Local().AddDate(0, 0, -3).Add(12 * time.Hour) // 12:00 of day -1
prof, err := importProfile(entity, from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
expected[i] = float64(0+96+2*i) / 2
}
require.Equal(t, expected, *prof, "full profile: expected %v, got %v", expected, *prof)
}
}

View file

@ -11,7 +11,6 @@ import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/cmd/shutdown"
@ -85,11 +84,10 @@ type Site struct {
coordinator *coordinator.Coordinator // Vehicles
prioritizer *prioritizer.Prioritizer // Power budgets
stats *Stats // Stats
fcstEnergy *meterEnergy
pvEnergy map[string]*meterEnergy
fcstEnergy *metrics.Accumulator
pvEnergy map[string]*metrics.Accumulator
householdEnergy *meterEnergy
householdSlotStart time.Time
homeEnergy, gridEnergy *metrics.Collector
// cached state
gridPower float64 // Grid power
@ -140,6 +138,12 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.prioritizer = prioritizer.New(log)
site.stats = NewStats()
me, err := metrics.NewCollector(metrics.Home, metrics.Home)
if err != nil {
return err
}
site.homeEnergy = me
// upload telemetry on shutdown
if telemetry.Enabled() {
shutdown.Register(func() {
@ -180,10 +184,17 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
site.gridMeter = dev.Instance()
if site.gridMeter == nil {
return errors.New("missing grid meter instance")
}
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef)
if err != nil {
return err
}
site.gridEnergy = me
}
// multiple pv
@ -195,7 +206,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.pvMeters = append(site.pvMeters, dev)
// accumulator
site.pvEnergy[ref] = &meterEnergy{clock: clock.New()}
site.pvEnergy[ref] = metrics.NewAccumulator()
}
// multiple batteries
@ -240,11 +251,10 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
// NewSite creates a Site with sane defaults
func NewSite() *Site {
site := &Site{
log: util.NewLogger("site"),
Voltage: 230, // V
pvEnergy: make(map[string]*meterEnergy),
fcstEnergy: &meterEnergy{clock: clock.New()},
householdEnergy: &meterEnergy{clock: clock.New()},
log: util.NewLogger("site"),
Voltage: 230, // V
pvEnergy: make(map[string]*metrics.Accumulator),
fcstEnergy: metrics.NewAccumulator(),
}
return site
@ -312,14 +322,14 @@ func (site *Site) restoreSettings() error {
}
// restore accumulated energy
pvEnergy := make(map[string]meterEnergy)
pvEnergy := make(map[string]metrics.Accumulator)
fcstEnergy, err := settings.Float(keys.SolarAccForecast)
if err == nil && settings.Json(keys.SolarAccYield, &pvEnergy) == nil {
var nok bool
for _, name := range site.Meters.PVMetersRef {
if fcst, ok := pvEnergy[name]; ok {
site.pvEnergy[name].Accumulated = fcst.Accumulated
site.pvEnergy[name].Import = fcst.Import
} else {
nok = true
site.log.WARN.Printf("accumulated solar yield: cannot restore %s", name)
@ -327,7 +337,7 @@ func (site *Site) restoreSettings() error {
}
if !nok {
site.fcstEnergy.Accumulated = fcstEnergy
site.fcstEnergy.Import = fcstEnergy
site.log.DEBUG.Printf("accumulated solar yield: restored %.3fkWh forecasted, %+v produced", fcstEnergy, pvEnergy)
} else {
// reset metrics
@ -337,7 +347,7 @@ func (site *Site) restoreSettings() error {
settings.Delete(keys.SolarAccYield)
for _, pe := range site.pvEnergy {
pe.Accumulated = 0
pe.Import = 0
}
}
}
@ -580,15 +590,15 @@ func (site *Site) updatePvMeters() {
// use stored devices, not ui-updated instances!
name := dev.Config().Name
prev := site.pvEnergy[name].AccumulatedEnergy()
prev := site.pvEnergy[name].Imported()
if mm[i].Energy > 0 {
site.log.DEBUG.Printf("!! solar production: accumulate set %s %.3fkWh meter total (was: %s)", name, mm[i].Energy, site.pvEnergy[name])
site.pvEnergy[name].AddMeterTotal(mm[i].Energy)
site.pvEnergy[name].SetImportMeterTotal(mm[i].Energy)
} else {
site.log.DEBUG.Printf("!! solar production: accumulate add %s %.3fW power (was: %s)", name, mm[i].Energy, site.pvEnergy[name])
site.pvEnergy[name].AddPower(mm[i].Power)
}
site.log.DEBUG.Printf("!! solar production: accumulate moved %s from %.3f to %.3f", name, prev, site.pvEnergy[name].AccumulatedEnergy())
site.log.DEBUG.Printf("!! solar production: accumulate moved %s from %.3f to %.3f", name, prev, site.pvEnergy[name].Imported())
}
// store
@ -751,14 +761,18 @@ func (site *Site) updateGridMeter() error {
}
// grid energy (import)
var importEnergy *float64
if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok {
if f, err := energyMeter.TotalEnergy(); err == nil {
mm.Energy = f
importEnergy = &f
} else {
site.log.ERROR.Printf("grid energy: %v", err)
}
}
site.gridEnergy.AddEnergy(importEnergy, nil, mm.Power)
site.publish(keys.Grid, mm)
return nil
@ -791,33 +805,6 @@ func optimizerEnabled() bool {
return exp && opt
}
func (site *Site) updateHomeConsumption(homePower float64) {
site.householdEnergy.AddPower(homePower)
now := site.householdEnergy.clock.Now()
if site.householdSlotStart.IsZero() {
site.householdSlotStart = now
return
}
slotDuration := 15 * time.Minute
slotStart := now.Truncate(slotDuration)
if slotStart.After(site.householdSlotStart) {
// next slot has started
if slotStart.Sub(site.householdSlotStart) >= slotDuration {
// more or less full slot
site.log.DEBUG.Printf("15min household consumption: %.0fWh", site.householdEnergy.Accumulated)
if err := metrics.Persist(site.householdSlotStart, site.householdEnergy.Accumulated); err != nil {
site.log.ERROR.Printf("persist household consumption: %v", err)
}
}
site.householdSlotStart = slotStart
site.householdEnergy.Accumulated = 0
}
}
// sitePower returns
// - the net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging
@ -963,7 +950,9 @@ func (site *Site) update(lp updater) {
site.publish(keys.HomePower, homePower)
if homePower > 0 {
site.updateHomeConsumption(homePower)
if err := site.homeEnergy.AddEnergy(nil, nil, homePower); err != nil {
site.log.ERROR.Printf("persist home consumption: %v", err)
}
}
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints

View file

@ -15,7 +15,6 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util/config"
@ -533,7 +532,7 @@ func loadpointProfile(lp loadpoint.API, minLen int) []float64 {
// homeProfile returns the home base load in Wh
func (site *Site) homeProfile(minLen int) ([]float64, error) {
// kWh over last 30 days
profile, err := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30))
profile, err := site.homeEnergy.ImportProfile(now.BeginningOfDay().AddDate(0, 0, -30))
if err != nil {
return nil, err
}

View file

@ -4,99 +4,14 @@ import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/jinzhu/now"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestSqliteTimestamp(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
clock := clock.NewMock()
clock.Add(time.Hour)
metrics.Persist(clock.Now(), 0)
db, err := db.Instance.DB()
require.NoError(t, err)
var (
ts metrics.SqlTime
val float64
)
for _, sql := range []string{
`SELECT ts, val FROM meters`,
`SELECT min(ts), val FROM meters`,
`SELECT unixepoch(ts), val FROM meters`,
`SELECT unixepoch(min(ts)), val FROM meters`,
`SELECT min(ts) AS ts, avg(val) AS val
FROM meters
GROUP BY strftime("%H:%M", ts)
ORDER BY ts`,
} {
require.NoError(t, db.QueryRow(sql).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
require.NoError(t, db.QueryRow(`SELECT ts, val FROM meters WHERE ts >= ?`, clock.Now()).Scan(&ts, &val))
require.True(t, clock.Now().Equal(time.Time(ts)), "expected %v, got %v", clock.Now().Local(), time.Time(ts).Local())
}
func TestUpdateHouseholdProfile(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
// make sure test data added starting 00:00 local time
clock := clock.NewMock()
clock.Set(now.With(clock.Now()).BeginningOfDay())
// 2 days of data
// day 1: 0 ... 95
// day 2: 96 ... 181
for i := range 4 * 2 * 24 {
metrics.Persist(clock.Now(), float64(i))
clock.Add(15 * time.Minute)
}
{
from := clock.Now().Local().AddDate(0, 0, -2).Add(12 * time.Hour) // 12:00 of day 0
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
if i < 48 {
expected[i] = float64(48+i+144+i) / 2
continue
}
expected[i] = float64(96 - 48 + i)
}
require.Equal(t, expected, *prof, "partial profile: expected %v, got %v", expected, *prof)
}
{
from := clock.Now().Local().AddDate(0, 0, -3).Add(12 * time.Hour) // 12:00 of day -1
prof, err := metrics.Profile(from)
require.NoError(t, err)
var expected [96]float64
for i := range expected {
expected[i] = float64(0+96+2*i) / 2
}
require.Equal(t, expected, *prof, "full profile: expected %v, got %v", expected, *prof)
}
}
func TestLoadpointProfile(t *testing.T) {
ctrl := gomock.NewController(t)

View file

@ -8,6 +8,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
@ -150,20 +151,21 @@ func (site *Site) solarDetails(solar api.Rates) solarDetails {
}
// accumulate forecasted energy since last update
energy := solarEnergy(solar, site.fcstEnergy.updated, time.Now()) / 1e3
fcstUpdated := site.fcstEnergy.Updated()
energy := solarEnergy(solar, fcstUpdated, time.Now()) / 1e3
site.log.DEBUG.Printf("solar forecast: accumulated %.3fWh from %v to %v",
energy, site.fcstEnergy.updated.Truncate(time.Second), time.Now().Truncate(time.Second),
energy, fcstUpdated.Truncate(time.Second), time.Now().Truncate(time.Second),
)
site.fcstEnergy.AddEnergy(energy)
settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Accumulated)
site.fcstEnergy.AddImportEnergy(energy)
settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Imported())
produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *meterEnergy) float64 {
return v.AccumulatedEnergy()
produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *metrics.Accumulator) float64 {
return v.Imported()
})
site.log.DEBUG.Printf("solar forecast: produced %.3f", produced)
if fcst := site.fcstEnergy.AccumulatedEnergy(); fcst > 0 {
if fcst := site.fcstEnergy.Imported(); fcst > 0 {
scale := produced / fcst
site.log.DEBUG.Printf("solar forecast: accumulated %.3fkWh, produced %.3fkWh, scale %.3f", fcst, produced, scale)

View file

@ -2,16 +2,11 @@ package core
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGreenShare(t *testing.T) {
@ -163,33 +158,3 @@ func TestRequiredBatteryMode(t *testing.T) {
assert.Equal(t, tc.res, res, "expected %s, got %s", tc.res, res)
}
}
func TestUpdateHomeConsumption(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
clock := clock.NewMock()
s := &Site{
log: util.NewLogger("foo"),
gridPower: 4e3,
householdEnergy: &meterEnergy{clock: clock},
}
require.True(t, s.householdEnergy.updated.IsZero())
clock.Add(5 * time.Minute)
s.updateHomeConsumption(1e3)
require.False(t, s.householdEnergy.updated.IsZero())
clock.Add(5 * time.Minute)
s.updateHomeConsumption(1e3)
require.Equal(t, 1e3*5/60/1e3, s.householdEnergy.AccumulatedEnergy()) // kWh
clock.Add(5 * time.Minute)
s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
clock.Add(15 * time.Minute)
s.updateHomeConsumption(1e3)
require.Equal(t, 0.0, s.householdEnergy.AccumulatedEnergy()) // accumulator reset after 15 minutes
}

View file

@ -159,6 +159,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
"deletesession": {"DELETE", "/session/{id:[0-9]+}", deleteSessionHandler},
"gridsessions": {"GET", "/gridsessions", gridSessionsHandler},
"energyhistory": {"GET", "/history/energy", energyHistoryHandler},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
}

View file

@ -0,0 +1,51 @@
package server
import (
"errors"
"net/http"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/server/db"
)
// energyHistoryHandler returns aggregated energy history data
func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
if db.Instance == nil {
jsonError(w, http.StatusBadRequest, errors.New("database offline"))
return
}
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
}
}
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
}
}
aggregate := q.Get("aggregate")
if aggregate == "" {
aggregate = "15m"
}
res, err := metrics.QueryImportEnergy(from, to, aggregate)
if err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
jsonWrite(w, res)
}

View file

@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
const from = "2026-03-24T21:00:00+01:00";
const to = "2026-03-25T01:00:00+01:00";
test.beforeAll(async () => {
await start(undefined, "energy-history.sql");
});
test.afterAll(async () => {
await stop();
});
test.describe("energy history API", () => {
test("15-minute resolution", async ({ request }) => {
const res = await request.get(`${baseUrl()}/api/history/energy`, {
params: { from, to },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: any) => s.name === "grid");
const home = data.find((s: any) => s.name === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
expect(grid.data).toHaveLength(6);
expect(home.data).toHaveLength(6);
expect(home.data[0].import).toBeCloseTo(0.1, 4);
expect(home.data[0].export).toBeCloseTo(0, 4);
expect(grid.data[0].import).toBeCloseTo(0.5, 4);
expect(grid.data[0].export).toBeCloseTo(0.1, 4);
});
test("day aggregation", async ({ request }) => {
const res = await request.get(`${baseUrl()}/api/history/energy`, {
params: { from, to, aggregate: "day" },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: any) => s.name === "grid");
const home = data.find((s: any) => s.name === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
expect(grid.data).toHaveLength(2);
expect(home.data).toHaveLength(2);
const [homeDay1, homeDay2] = home.data;
const [gridDay1, gridDay2] = grid.data;
// day 1 (2026-03-24): 4 slots
expect(homeDay1.import).toBeCloseTo(0.4, 4);
expect(homeDay1.export).toBeCloseTo(0, 4);
expect(gridDay1.import).toBeCloseTo(2.0, 4);
expect(gridDay1.export).toBeCloseTo(0.4, 4);
// day 2 (2026-03-25): 2 slots
expect(homeDay2.import).toBeCloseTo(0.2, 4);
expect(homeDay2.export).toBeCloseTo(0, 4);
expect(gridDay2.import).toBeCloseTo(1.0, 4);
expect(gridDay2.export).toBeCloseTo(0.2, 4);
});
});

42
tests/energy-history.sql Normal file
View file

@ -0,0 +1,42 @@
DROP TABLE IF EXISTS `meters`;
DROP TABLE IF EXISTS `entities`;
CREATE TABLE `entities` (
`id` integer,
`group` text,
`name` text,
PRIMARY KEY (`id`)
);
CREATE UNIQUE INDEX `entities_group_name` ON `entities`(`group`, `name`);
CREATE TABLE `meters` (
`meter` integer,
`ts` datetime,
`import` real,
`export` real
);
CREATE UNIQUE INDEX `meters_meter_ts` ON `meters`(`meter`, `ts`);
-- entities
INSERT INTO `entities` VALUES (1, 'virtual', 'home');
INSERT INTO `entities` VALUES (2, 'grid', 'grid');
-- meter data: 6 slots per entity spanning midnight (local time +01:00)
-- 2026-03-24: 22:00, 22:15, 22:30, 22:45 (4 slots)
-- 2026-03-25: 00:00, 00:15 (2 slots)
-- home (id=1): import=0.1, export=0 per slot
INSERT INTO `meters` VALUES (1, '2026-03-24 22:00:00+01:00', 0.1, 0);
INSERT INTO `meters` VALUES (1, '2026-03-24 22:15:00+01:00', 0.1, 0);
INSERT INTO `meters` VALUES (1, '2026-03-24 22:30:00+01:00', 0.1, 0);
INSERT INTO `meters` VALUES (1, '2026-03-24 22:45:00+01:00', 0.1, 0);
INSERT INTO `meters` VALUES (1, '2026-03-25 00:00:00+01:00', 0.1, 0);
INSERT INTO `meters` VALUES (1, '2026-03-25 00:15:00+01:00', 0.1, 0);
-- grid (id=2): import=0.5, export=0.1 per slot
INSERT INTO `meters` VALUES (2, '2026-03-24 22:00:00+01:00', 0.5, 0.1);
INSERT INTO `meters` VALUES (2, '2026-03-24 22:15:00+01:00', 0.5, 0.1);
INSERT INTO `meters` VALUES (2, '2026-03-24 22:30:00+01:00', 0.5, 0.1);
INSERT INTO `meters` VALUES (2, '2026-03-24 22:45:00+01:00', 0.5, 0.1);
INSERT INTO `meters` VALUES (2, '2026-03-25 00:00:00+01:00', 0.5, 0.1);
INSERT INTO `meters` VALUES (2, '2026-03-25 00:15:00+01:00', 0.5, 0.1);