Collect 15min energy metrics (#23185)
This commit is contained in:
parent
b6fda32128
commit
81b78db887
22 changed files with 1375 additions and 266 deletions
|
|
@ -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>
|
||||
|
|
|
|||
177
assets/js/components/History/Chart.vue
Normal file
177
assets/js/components/History/Chart.vue
Normal 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>
|
||||
170
assets/js/components/History/EnergyChart.vue
Normal file
170
assets/js/components/History/EnergyChart.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
103
assets/js/views/History.vue
Normal 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>
|
||||
|
|
@ -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
126
core/metrics/accumulator.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
126
core/metrics/collector.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
132
core/metrics/collector_test.go
Normal file
132
core/metrics/collector_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
102
core/metrics/db_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
81
core/site.go
81
core/site.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
}
|
||||
|
||||
|
|
|
|||
51
server/http_history_handler.go
Normal file
51
server/http_history_handler.go
Normal 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)
|
||||
}
|
||||
72
tests/energy-history.spec.ts
Normal file
72
tests/energy-history.spec.ts
Normal 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
42
tests/energy-history.sql
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue