Metrics: store entity title on lazy-create (#30196)
This commit is contained in:
parent
0fa8b97daf
commit
a8361b575b
21 changed files with 209 additions and 142 deletions
|
|
@ -27,7 +27,7 @@ export interface HistorySlot {
|
|||
}
|
||||
|
||||
export interface HistorySeries {
|
||||
name: string;
|
||||
title: string;
|
||||
group: string;
|
||||
data: HistorySlot[];
|
||||
// Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral
|
||||
|
|
@ -203,14 +203,14 @@ export default defineComponent({
|
|||
const mutedColor = colors.muted || this.color;
|
||||
const titles: string[] = [];
|
||||
for (const s of this.series) {
|
||||
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
|
||||
if (!s.virtual && !titles.includes(s.title)) titles.push(s.title);
|
||||
}
|
||||
const palette = resolveColors(titles, deviceColorMap(store.state.deviceColors));
|
||||
return this.series.map((s) => {
|
||||
// Virtual "other consumers" entity renders in a neutral gray to set
|
||||
// it apart from explicit meter entities.
|
||||
if (s.virtual) return mutedColor;
|
||||
return palette[s.name] || this.color;
|
||||
return palette[s.title] || this.color;
|
||||
});
|
||||
}
|
||||
if (this.series.length <= 1) return [this.color];
|
||||
|
|
@ -512,7 +512,7 @@ export default defineComponent({
|
|||
? [this.focusedEntity]
|
||||
: this.series.map((s, i) => s.paletteIndex ?? i);
|
||||
const nameByIdx = new Map(
|
||||
this.series.map((s, i) => [s.paletteIndex ?? i, s.name])
|
||||
this.series.map((s, i) => [s.paletteIndex ?? i, s.title])
|
||||
);
|
||||
const showName = this.series.length > 1 && this.focusedEntity === null;
|
||||
|
||||
|
|
@ -692,15 +692,15 @@ export default defineComponent({
|
|||
directionLabel(s: HistorySeries, dir: "energy" | "returnEnergy"): string {
|
||||
const key = `main.history.direction.${s.group}.${dir}`;
|
||||
const label = this.$t(key);
|
||||
if (label === key) return s.name;
|
||||
if (this.series.length > 1) return `${s.name} ${label}`;
|
||||
if (label === key) return s.title;
|
||||
if (this.series.length > 1) return `${s.title} ${label}`;
|
||||
return String(label);
|
||||
},
|
||||
singleEntityName(s: HistorySeries): string {
|
||||
if (this.series.length > 1) return s.name;
|
||||
if (this.series.length > 1) return s.title;
|
||||
const key = `main.history.group.${s.group}`;
|
||||
const label = this.$t(key);
|
||||
return label === key ? s.name : String(label);
|
||||
return label === key ? s.title : String(label);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { computed, reactive } from "vue";
|
||||
import { reactive } from "vue";
|
||||
import type { State } from "./types/evcc";
|
||||
import { convertToUiLoadpoints } from "./uiLoadpoints";
|
||||
import { useDebouncedComputed } from "./utils/useDebouncedComputed";
|
||||
|
|
@ -43,25 +43,9 @@ const uiLoadpoints = useDebouncedComputed(
|
|||
50
|
||||
);
|
||||
|
||||
// name → title lookup for loadpoints and meters
|
||||
const deviceTitles = computed(() => {
|
||||
const map: Record<string, string> = {};
|
||||
const add = (e: { name?: string; title?: string } | undefined) => {
|
||||
if (e?.name && e.title) map[e.name] = e.title;
|
||||
};
|
||||
state.loadpoints?.forEach(add);
|
||||
state.pv?.forEach(add);
|
||||
state.battery?.devices?.forEach(add);
|
||||
state.aux?.forEach(add);
|
||||
state.ext?.forEach(add);
|
||||
add(state.grid);
|
||||
return map;
|
||||
});
|
||||
|
||||
export interface Store {
|
||||
state: State; // raw state from websocket
|
||||
uiLoadpoints: typeof uiLoadpoints;
|
||||
deviceTitles: typeof deviceTitles;
|
||||
offline(value: boolean): void;
|
||||
update(msg: any): void;
|
||||
reset(): void;
|
||||
|
|
@ -70,7 +54,6 @@ export interface Store {
|
|||
const store: Store = {
|
||||
state,
|
||||
uiLoadpoints,
|
||||
deviceTitles,
|
||||
offline(value: boolean) {
|
||||
state.offline = value;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -245,12 +245,10 @@ export default defineComponent({
|
|||
return `${this.aggregate}|${this.from.getTime()}|${this.to.getTime()}`;
|
||||
},
|
||||
seriesByGroup(): Record<string, HistorySeries[]> {
|
||||
const titles = store.deviceTitles.value;
|
||||
const map: Record<string, HistorySeries[]> = {};
|
||||
for (const s of this.rawSeries) {
|
||||
if (!s.group) continue;
|
||||
if (!map[s.group]) map[s.group] = [];
|
||||
map[s.group]!.push({ ...s, name: titles[s.name] || s.name });
|
||||
(map[s.group] ||= []).push(s);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
|
|
@ -284,7 +282,7 @@ export default defineComponent({
|
|||
const activeMeters = meters
|
||||
.map((s, i) => ({ ...s, paletteIndex: i }))
|
||||
.filter(hasEnergy);
|
||||
if (!home) return activeMeters;
|
||||
if (!home || activeMeters.length === 0) return activeMeters;
|
||||
const meterTotals = new Map<string, number>();
|
||||
// Net per slot is computed from all meters (incl. inactive ones), so
|
||||
// dropping inactive entries from the display doesn't shift the
|
||||
|
|
@ -296,7 +294,7 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
const other: HistorySeries = {
|
||||
name: this.$t("main.history.otherConsumers") as string,
|
||||
title: this.$t("main.history.otherConsumers") as string,
|
||||
group: "meter",
|
||||
virtual: true,
|
||||
// Use meters.length as a stable paletteIndex that can never
|
||||
|
|
@ -309,9 +307,7 @@ export default defineComponent({
|
|||
}),
|
||||
};
|
||||
if (!hasEnergy(other)) return activeMeters;
|
||||
// First entry in the array = bottom of the stack, so "Other consumers"
|
||||
// always sits underneath the explicit meters.
|
||||
return [other, ...activeMeters];
|
||||
return [...activeMeters, other];
|
||||
};
|
||||
},
|
||||
hasForecast(): boolean {
|
||||
|
|
@ -417,14 +413,14 @@ export default defineComponent({
|
|||
if (isPickGroup) {
|
||||
const titles: string[] = [];
|
||||
for (const s of list) {
|
||||
if (!s.virtual && !titles.includes(s.name)) titles.push(s.name);
|
||||
if (!s.virtual && !titles.includes(s.title)) titles.push(s.title);
|
||||
}
|
||||
palette = resolveColors(titles, this.deviceColors);
|
||||
}
|
||||
|
||||
const colorFor = (i: number, s: HistorySeries) => {
|
||||
if (s.virtual) return colors.muted || baseColor;
|
||||
if (isPickGroup) return palette[s.name] || baseColor;
|
||||
if (isPickGroup) return palette[s.title] || baseColor;
|
||||
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||
};
|
||||
return list.map((s, i) => {
|
||||
|
|
@ -435,10 +431,10 @@ export default defineComponent({
|
|||
// Use stable paletteIndex as the focus identifier so that the
|
||||
// selected entity keeps its identity across period navigations.
|
||||
entityIndex: s.paletteIndex ?? i,
|
||||
label: s.name,
|
||||
label: s.title,
|
||||
color: colorFor(i, s),
|
||||
value: this.fmtWh(watts, POWER_UNIT.AUTO),
|
||||
id: isPickGroup && !s.virtual ? s.name : undefined,
|
||||
id: isPickGroup && !s.virtual ? s.title : undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import (
|
|||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
|
||||
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// OCPP charger implementation
|
||||
|
|
@ -138,7 +137,7 @@ func NewOCPP(ctx context.Context,
|
|||
forcePowerCtrl, stackLevelZero, profileKindRelative, remoteStart, noChangeAvailability bool,
|
||||
connectTimeout time.Duration,
|
||||
) (*OCPP, error) {
|
||||
log := util.NewLogger(fmt.Sprintf("%s-%d", lo.CoalesceOrEmpty(id, "ocpp"), connector))
|
||||
log := util.NewLogger(fmt.Sprintf("%s-%d", cmp.Or(id, "ocpp"), connector))
|
||||
|
||||
cp, err := ocpp.Instance().RegisterChargepoint(id,
|
||||
func() *ocpp.CP {
|
||||
|
|
@ -167,7 +166,7 @@ func NewOCPP(ctx context.Context,
|
|||
}
|
||||
|
||||
if remoteStart {
|
||||
idTag = lo.CoalesceOrEmpty(idTag, cp.IdTag, defaultIdTag)
|
||||
idTag = cmp.Or(idTag, cp.IdTag, defaultIdTag)
|
||||
}
|
||||
|
||||
conn, err := ocpp.NewConnector(ctx, log, connector, cp, idTag, meterInterval)
|
||||
|
|
|
|||
|
|
@ -78,19 +78,19 @@ type batteryTotals struct {
|
|||
discharge float64
|
||||
}
|
||||
|
||||
// metricsBatteryTotals sums charge and discharge energy per battery entity.
|
||||
// metricsBatteryTotals sums charge and discharge energy per battery title.
|
||||
func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals {
|
||||
res := make(map[string]batteryTotals)
|
||||
for _, s := range series {
|
||||
if s.Group != metrics.Battery {
|
||||
continue
|
||||
}
|
||||
t := res[s.Name]
|
||||
t := res[s.Title]
|
||||
for _, slot := range s.Data {
|
||||
t.charge += slot.Energy
|
||||
t.discharge += slot.ReturnEnergy
|
||||
}
|
||||
res[s.Name] = t
|
||||
res[s.Title] = t
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals
|
|||
fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency")
|
||||
|
||||
for _, e := range selected {
|
||||
t := totals[e.Name]
|
||||
t := totals[title(e.Group, e.Name)]
|
||||
|
||||
efficiency := ""
|
||||
if t.charge > 0 {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import (
|
|||
|
||||
func TestMetricsBatteryTotals(t *testing.T) {
|
||||
series := []metrics.Series{
|
||||
{Group: metrics.Battery, Name: "bat", Data: []metrics.Slot{
|
||||
{Group: metrics.Battery, Title: "bat", Data: []metrics.Slot{
|
||||
{Energy: 1.0, ReturnEnergy: 0.4},
|
||||
{Energy: 2.0, ReturnEnergy: 1.6},
|
||||
}},
|
||||
// non-battery series must be ignored
|
||||
{Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{
|
||||
{Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{
|
||||
{Energy: 5.0, ReturnEnergy: 3.0},
|
||||
}},
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ func TestMetricsWriteBatteryTable(t *testing.T) {
|
|||
{Group: metrics.Battery, Name: "bat2"},
|
||||
}
|
||||
totals := map[string]batteryTotals{
|
||||
"bat1": {charge: 10.0, discharge: 9.0},
|
||||
"Home": {charge: 10.0, discharge: 9.0},
|
||||
// bat2 deliberately absent: no data in the timeframe
|
||||
}
|
||||
title := func(group, name string) string {
|
||||
|
|
|
|||
|
|
@ -72,13 +72,19 @@ func runMetricsData(cmd *cobra.Command, args []string) {
|
|||
|
||||
byEntity := make(map[string]metrics.Series, len(series))
|
||||
for _, s := range series {
|
||||
byEntity[s.Group+"/"+s.Name] = s
|
||||
byEntity[s.Group+"/"+s.Title] = s
|
||||
}
|
||||
|
||||
if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV {
|
||||
var out metrics.SeriesCSV
|
||||
seen := make(map[string]bool, len(selected))
|
||||
for _, e := range selected {
|
||||
if s, ok := byEntity[e.Group+"/"+e.Name]; ok {
|
||||
key := e.Group + "/" + title(e.Group, e.Name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if s, ok := byEntity[key]; ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +242,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[
|
|||
}
|
||||
|
||||
for _, spec := range specs {
|
||||
s, ok := byEntity[spec.entity.Group+"/"+spec.entity.Name]
|
||||
s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,10 +93,10 @@ func TestMetricsWriteTable(t *testing.T) {
|
|||
{Group: metrics.Grid, Name: "grid"},
|
||||
}
|
||||
byEntity := map[string]metrics.Series{
|
||||
metrics.Loadpoint + "/lp-1": {Group: metrics.Loadpoint, Name: "lp-1", Data: []metrics.Slot{
|
||||
metrics.Loadpoint + "/Carport": {Group: metrics.Loadpoint, Title: "Carport", Data: []metrics.Slot{
|
||||
{Start: h0, Energy: 1.84},
|
||||
}},
|
||||
metrics.Grid + "/grid": {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{
|
||||
metrics.Grid + "/grid": {Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{
|
||||
{Start: h0, Energy: 0.412},
|
||||
{Start: h1, Energy: 0.38, ReturnEnergy: 0.05},
|
||||
}},
|
||||
|
|
@ -105,6 +105,9 @@ func TestMetricsWriteTable(t *testing.T) {
|
|||
if group == metrics.Loadpoint {
|
||||
return "Carport"
|
||||
}
|
||||
if group == metrics.Grid {
|
||||
return "grid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@ import (
|
|||
|
||||
func TestMetricsForecastTotals(t *testing.T) {
|
||||
series := []metrics.Series{
|
||||
{Group: metrics.Forecast, Name: "forecast", Data: []metrics.Slot{
|
||||
{Group: metrics.Forecast, Title: "forecast", Data: []metrics.Slot{
|
||||
{Energy: 10.0}, {Energy: 5.0},
|
||||
}},
|
||||
// actual PV production is summed across all pv entities
|
||||
{Group: metrics.PV, Name: "pv1", Data: []metrics.Slot{
|
||||
{Group: metrics.PV, Title: "pv1", Data: []metrics.Slot{
|
||||
{Energy: 4.0}, {Energy: 3.0},
|
||||
}},
|
||||
{Group: metrics.PV, Name: "pv2", Data: []metrics.Slot{
|
||||
{Group: metrics.PV, Title: "pv2", Data: []metrics.Slot{
|
||||
{Energy: 2.0},
|
||||
}},
|
||||
// other groups are ignored
|
||||
{Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{
|
||||
{Group: metrics.Grid, Title: "grid", Data: []metrics.Slot{
|
||||
{Energy: 99.0},
|
||||
}},
|
||||
}
|
||||
|
|
|
|||
16
cmd/setup.go
16
cmd/setup.go
|
|
@ -1305,12 +1305,24 @@ func configureSite(conf map[string]any, loadpoints []*core.Loadpoint, tariffs *t
|
|||
func newLoadpoint(idx int, name string, other map[string]any, settingsFn func(*util.Logger) coresettings.Settings) (*core.Loadpoint, error) {
|
||||
log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(idx), idx)
|
||||
|
||||
collector, err := metrics.NewCollector(metrics.Loadpoint, name)
|
||||
collector, err := metrics.NewCollector(metrics.Loadpoint, name, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return core.NewLoadpointFromConfig(log, settingsFn(log), collector, other)
|
||||
lp, err := core.NewLoadpointFromConfig(log, settingsFn(log), collector, other)
|
||||
if err != nil {
|
||||
return lp, err
|
||||
}
|
||||
|
||||
// lazily update entity title
|
||||
if title := lp.GetTitle(); title != "" {
|
||||
if err := collector.UpdateTitle(title); err != nil {
|
||||
return lp, err
|
||||
}
|
||||
}
|
||||
|
||||
return lp, nil
|
||||
}
|
||||
|
||||
func configureLoadpoints(conf globalconfig.All) error {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
|
|
@ -64,12 +65,7 @@ func deviceProperties[T any](dev config.Device[T]) config.Properties {
|
|||
|
||||
// deviceTitleOrName returns device title or name
|
||||
func deviceTitleOrName[T any](dev config.Device[T]) string {
|
||||
if d, ok := dev.(config.ConfigurableDevice[T]); ok {
|
||||
if title := d.Properties().Title; title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return dev.Config().Name
|
||||
return cmp.Or(deviceProperties(dev).Title, dev.Config().Name)
|
||||
}
|
||||
|
||||
// circuitMaxPower returns a circuits power limit
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ type Collector struct {
|
|||
started time.Time
|
||||
}
|
||||
|
||||
func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, error) {
|
||||
entity, err := createEntity(group, name)
|
||||
func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) {
|
||||
entity, err := createEntity(group, name, title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -38,17 +38,30 @@ func NewCollector(group, name string, opt ...func(*Accumulator)) (*Collector, er
|
|||
return c, nil
|
||||
}
|
||||
|
||||
func createEntity(group, name string) (entity, error) {
|
||||
entity := entity{
|
||||
Group: group,
|
||||
Name: name,
|
||||
// createEntity ensures the entity row exists and refreshes its title.
|
||||
func createEntity(group, name, title string) (entity, error) {
|
||||
e := entity{Group: group, Name: name}
|
||||
|
||||
if err := db.Instance.Where(&e).Attrs(entity{Title: title}).FirstOrCreate(&e).Error; err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
if err := db.Instance.Where(&entity).FirstOrCreate(&entity).Error; err != nil {
|
||||
return entity, err
|
||||
return e, e.updateTitle(title)
|
||||
}
|
||||
|
||||
// updateTitle refreshes the entity's stored title if it changed
|
||||
func (e *entity) updateTitle(title string) error {
|
||||
if title == "" || e.Title == title {
|
||||
return nil
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
e.Title = title
|
||||
return db.Instance.Model(e).UpdateColumn("title", title).Error
|
||||
}
|
||||
|
||||
// UpdateTitle refreshes the collector entity's stored title if it changed.
|
||||
func (c *Collector) UpdateTitle(title string) error {
|
||||
return c.entity.updateTitle(title)
|
||||
}
|
||||
|
||||
func (c *Collector) process(fun func()) error {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func TestCollectorAddEnergy(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("foo", "foo", WithClock(clock))
|
||||
col, err := NewCollector("foo", "foo", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
require.True(t, col.accu.updated.IsZero())
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ func TestCollectorAddEnergyWithImportMeter(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("bar", "bar", WithClock(clock))
|
||||
col, err := NewCollector("bar", "bar", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// first call: seeds meter, no delta yet
|
||||
|
|
@ -67,7 +67,7 @@ func TestCollectorAddEnergyWithImportMeterAndExport(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("baz", "baz", WithClock(clock))
|
||||
col, err := NewCollector("baz", "baz", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// seed import meter
|
||||
|
|
@ -93,7 +93,7 @@ func TestCollectorAddEnergyWithExportMeterAndImport(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("baz2", "baz2", WithClock(clock))
|
||||
col, err := NewCollector("baz2", "baz2", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// seed export meter
|
||||
|
|
@ -119,7 +119,7 @@ func TestCollectorAddEnergyWithBothMeters(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("qux", "qux", WithClock(clock))
|
||||
col, err := NewCollector("qux", "qux", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// seed both meters
|
||||
|
|
@ -139,7 +139,7 @@ func TestCollectorSetImportAndExportMeterTotal(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("set", "set", WithClock(clock))
|
||||
col, err := NewCollector("set", "set", "", WithClock(clock))
|
||||
require.NoError(t, err)
|
||||
|
||||
// seed both import and export
|
||||
|
|
@ -163,7 +163,7 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) {
|
|||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
col, err := NewCollector("partial", "partial", WithClock(clk))
|
||||
col, err := NewCollector("partial", "partial", "", WithClock(clk))
|
||||
require.NoError(t, err)
|
||||
|
||||
// first update mid-slot (00:05) - slot 00:00 is only partially covered
|
||||
|
|
@ -191,3 +191,47 @@ func TestCollectorSkipsPartialFirstSlot(t *testing.T) {
|
|||
require.NoError(t, db.Instance.First(&m).Error)
|
||||
require.Equal(t, int64(15*60), m.Timestamp, "persisted slot should start at 00:15")
|
||||
}
|
||||
|
||||
// TestCreateEntityRefreshesTitle verifies that a second call to createEntity
|
||||
// with a non-empty title fills in (or updates) the title on an existing row,
|
||||
// and that passing an empty title never clears a previously stored value.
|
||||
func TestCreateEntityRefreshesTitle(t *testing.T) {
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, SetupSchema())
|
||||
|
||||
// existing row with empty title (simulates pre-upgrade data)
|
||||
first, err := createEntity("grid", "grid", "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, first.Title)
|
||||
|
||||
// lazy-create with a real title must persist it
|
||||
second, err := createEntity("grid", "grid", "House meter")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first.Id, second.Id, "should be the same row")
|
||||
require.Equal(t, "House meter", second.Title)
|
||||
|
||||
var stored entity
|
||||
require.NoError(t, db.Instance.First(&stored, first.Id).Error)
|
||||
require.Equal(t, "House meter", stored.Title, "title must be persisted")
|
||||
|
||||
// subsequent call with empty title must not clear the existing one
|
||||
third, err := createEntity("grid", "grid", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "House meter", third.Title)
|
||||
|
||||
require.NoError(t, db.Instance.First(&stored, first.Id).Error)
|
||||
require.Equal(t, "House meter", stored.Title, "title must survive empty re-create")
|
||||
|
||||
// changing the title overwrites the stored value
|
||||
fourth, err := createEntity("grid", "grid", "Grid")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Grid", fourth.Title)
|
||||
|
||||
require.NoError(t, db.Instance.First(&stored, first.Id).Error)
|
||||
require.Equal(t, "Grid", stored.Title)
|
||||
|
||||
// only one row exists despite four createEntity calls with varying titles
|
||||
var count int64
|
||||
require.NoError(t, db.Instance.Model(new(entity)).Where("\"group\" = ? AND name = ?", "grid", "grid").Count(&count).Error)
|
||||
require.EqualValues(t, 1, count, "must not duplicate existing rows")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ 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"`
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
@ -41,7 +42,7 @@ func SetupSchema() error {
|
|||
}
|
||||
|
||||
// ensure home entity exists (reserves id=1 for legacy meter FK references)
|
||||
if _, err := createEntity(Home, Home); err != nil {
|
||||
if _, err := createEntity(Home, Home, Home); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
type EntityInfo struct {
|
||||
Group string
|
||||
Name string
|
||||
Title string // human-readable title as captured at the last lazy-create
|
||||
Slots int // number of persisted 15min slots
|
||||
First time.Time // start of the earliest slot, zero if no data
|
||||
Last time.Time // start of the latest slot, zero if no data
|
||||
|
|
@ -23,6 +24,7 @@ func ListEntities() ([]EntityInfo, error) {
|
|||
type row struct {
|
||||
Group string
|
||||
Name string
|
||||
Title string
|
||||
Slots int
|
||||
First sql.NullInt64
|
||||
Last sql.NullInt64
|
||||
|
|
@ -30,7 +32,7 @@ func ListEntities() ([]EntityInfo, error) {
|
|||
|
||||
var rows []row
|
||||
if err := db.Instance.Table("entities e").
|
||||
Select(`e."group" AS "group", e.name AS name,
|
||||
Select(`e."group" AS "group", e.name AS name, e.title AS title,
|
||||
COUNT(m.ts) AS slots,
|
||||
MIN(m.ts) AS first, MAX(m.ts) AS last`).
|
||||
Joins("LEFT JOIN meters m ON m.meter = e.id").
|
||||
|
|
@ -41,7 +43,7 @@ func ListEntities() ([]EntityInfo, error) {
|
|||
|
||||
res := make([]EntityInfo, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := EntityInfo{Group: r.Group, Name: r.Name, Slots: r.Slots}
|
||||
e := EntityInfo{Group: r.Group, Name: r.Name, Title: r.Title, Slots: r.Slots}
|
||||
if r.First.Valid {
|
||||
e.First = time.Unix(r.First.Int64, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ func roundEnergy(v float64) float64 {
|
|||
return max(0, math.Round(v*1000)/1000)
|
||||
}
|
||||
|
||||
// Series represents a named series of energy slots
|
||||
// Series represents an energy series for one title group or one entity group.
|
||||
type Series struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Group string `json:"group"`
|
||||
Data []Slot `json:"data"`
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ var aggregateDurations = map[string]func(time.Time) time.Time{
|
|||
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
|
||||
}
|
||||
|
||||
// QueryEnergy returns aggregated energy data, per entity or per group.
|
||||
// QueryEnergy returns aggregated energy data, per title or per group.
|
||||
func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) {
|
||||
addDuration := aggregateDurations[aggregate]
|
||||
|
||||
|
|
@ -64,13 +64,18 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
|
|||
return nil, errors.New("invalid aggregate value")
|
||||
}
|
||||
|
||||
groupCols := `e."group", ` + fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format)
|
||||
if !grouped {
|
||||
groupCols = "e.name, " + groupCols
|
||||
titleExpr := `COALESCE(NULLIF(e.title,''), e.name)`
|
||||
timeCol := fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format)
|
||||
|
||||
selectTitle := titleExpr + ` AS title`
|
||||
groupCols := titleExpr + `, e."group", ` + timeCol
|
||||
if grouped {
|
||||
selectTitle = `'' AS title`
|
||||
groupCols = `e."group", ` + timeCol
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Name string
|
||||
Title string
|
||||
Group string
|
||||
Start SqlTime
|
||||
Energy float64
|
||||
|
|
@ -78,7 +83,7 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
|
|||
}
|
||||
|
||||
tx := db.Instance.Table("meters m").
|
||||
Select(`e.name, e."group",
|
||||
Select(selectTitle + `, e."group",
|
||||
MIN(m.ts) AS start,
|
||||
COALESCE(SUM(m.energy), 0) AS energy,
|
||||
COALESCE(SUM(m.return_energy), 0) AS return_energy`).
|
||||
|
|
@ -100,13 +105,8 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
|
|||
|
||||
var res []Series
|
||||
for _, r := range rows {
|
||||
name := r.Name
|
||||
if grouped {
|
||||
name = ""
|
||||
}
|
||||
|
||||
if n := len(res); n == 0 || res[n-1].Name != name || res[n-1].Group != r.Group {
|
||||
res = append(res, Series{Name: name, Group: r.Group})
|
||||
if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group {
|
||||
res = append(res, Series{Title: r.Title, Group: r.Group})
|
||||
}
|
||||
|
||||
s := &res[len(res)-1]
|
||||
|
|
@ -180,19 +180,30 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
|
|||
tsSet := make(map[int64]time.Time)
|
||||
endByStart := make(map[int64]time.Time)
|
||||
|
||||
label := func(e *Series, g string) string {
|
||||
if e.Title != "" {
|
||||
return e.Title
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
prefix := func(g, l string) string {
|
||||
if l == g {
|
||||
return g
|
||||
}
|
||||
return g + "." + l
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
entities := byGroup[g]
|
||||
sort.Slice(entities, func(i, j int) bool { return entities[i].Name < entities[j].Name })
|
||||
sort.Slice(entities, func(i, j int) bool { return label(entities[i], g) < label(entities[j], g) })
|
||||
|
||||
for _, e := range entities {
|
||||
name := e.Name
|
||||
if name == "" {
|
||||
name = g
|
||||
}
|
||||
header = append(header, g+"."+name+".energy.Wh")
|
||||
p := prefix(g, label(e, g))
|
||||
header = append(header, p+".energy.Wh")
|
||||
cols = append(cols, col{series: e, returnEnergy: false})
|
||||
if hasReturnEnergy(g) {
|
||||
header = append(header, g+"."+name+".returnEnergy.Wh")
|
||||
header = append(header, p+".returnEnergy.Wh")
|
||||
cols = append(cols, col{series: e, returnEnergy: true})
|
||||
}
|
||||
for _, slot := range e.Data {
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
|
|||
t1 := t0.Add(15 * time.Minute)
|
||||
|
||||
series := SeriesCSV{
|
||||
{Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}},
|
||||
{Group: Battery, Name: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}},
|
||||
{Group: Battery, Name: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}},
|
||||
{Group: Grid, Name: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}},
|
||||
{Group: Home, Name: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
|
||||
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}},
|
||||
{Group: Battery, Title: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}},
|
||||
{Group: Battery, Title: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}},
|
||||
{Group: Grid, Title: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}},
|
||||
{Group: Home, Title: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -42,13 +42,13 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
|
|||
header := rows[0]
|
||||
expected := []string{
|
||||
"time.start", "time.end",
|
||||
"pv.pv.energy.Wh",
|
||||
"pv.energy.Wh",
|
||||
"battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh",
|
||||
"battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh",
|
||||
"grid.grid.energy.Wh", "grid.grid.returnEnergy.Wh",
|
||||
"home.home.energy.Wh",
|
||||
"grid.energy.Wh", "grid.returnEnergy.Wh",
|
||||
"home.energy.Wh",
|
||||
}
|
||||
require.Equal(t, expected, header, "header order: GROUP_ORDER, alphabetical entities; returnEnergy only for grid/battery")
|
||||
require.Equal(t, expected, header, "single-entity groups omit the title level; multi-entity groups include the title")
|
||||
|
||||
// time.end = time.start + slot length (15 min in mkSlot)
|
||||
require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0])
|
||||
|
|
@ -68,7 +68,7 @@ func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
|
|||
func TestSeriesCSV_GermanLocale(t *testing.T) {
|
||||
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
|
||||
series := SeriesCSV{
|
||||
{Group: PV, Name: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
|
||||
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -91,8 +91,8 @@ func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) {
|
|||
// Two entities, second one only has data for the first timestamp → second
|
||||
// timestamp must produce an empty cell rather than 0.000.
|
||||
series := SeriesCSV{
|
||||
{Group: PV, Name: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
|
||||
{Group: PV, Name: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
|
||||
{Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
|
||||
{Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
@ -119,7 +119,7 @@ func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
|
|||
// returnEnergy column.
|
||||
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
|
||||
series := SeriesCSV{
|
||||
{Group: Battery, Name: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
|
||||
{Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ func TestQueryEnergyGrouped(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Len(t, res, 1)
|
||||
require.Equal(t, Grid, res[0].Group)
|
||||
require.Empty(t, res[0].Name)
|
||||
require.Empty(t, res[0].Title)
|
||||
require.Len(t, res[0].Data, 2)
|
||||
require.InDelta(t, 1+2, res[0].Data[0].Energy, 0.001)
|
||||
require.InDelta(t, 3+4, res[0].Data[1].Energy, 0.001)
|
||||
|
|
@ -138,19 +138,19 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Len(t, res, 3)
|
||||
|
||||
byName := map[string]Series{}
|
||||
byTitle := map[string]Series{}
|
||||
for _, s := range res {
|
||||
require.Len(t, s.Data, 2)
|
||||
byName[s.Name] = s
|
||||
byTitle[s.Title] = s
|
||||
}
|
||||
require.Equal(t, Grid, byName[Grid].Group)
|
||||
require.Equal(t, PV, byName["pv1"].Group)
|
||||
require.Equal(t, PV, byName["pv2"].Group)
|
||||
require.Equal(t, Grid, byTitle[Grid].Group)
|
||||
require.Equal(t, PV, byTitle["pv1"].Group)
|
||||
require.Equal(t, PV, byTitle["pv2"].Group)
|
||||
|
||||
require.InDelta(t, 1, byName[Grid].Data[0].Energy, 0.001)
|
||||
require.InDelta(t, 2, byName[Grid].Data[1].Energy, 0.001)
|
||||
require.InDelta(t, 10, byName["pv1"].Data[0].ReturnEnergy, 0.001)
|
||||
require.InDelta(t, 21, byName["pv2"].Data[1].ReturnEnergy, 0.001)
|
||||
require.InDelta(t, 1, byTitle[Grid].Data[0].Energy, 0.001)
|
||||
require.InDelta(t, 2, byTitle[Grid].Data[1].Energy, 0.001)
|
||||
require.InDelta(t, 10, byTitle["pv1"].Data[0].ReturnEnergy, 0.001)
|
||||
require.InDelta(t, 21, byTitle["pv2"].Data[1].ReturnEnergy, 0.001)
|
||||
|
||||
// grouped: 2 series, pv summed per bucket
|
||||
res, err = QueryEnergy(from, to, "hour", true)
|
||||
|
|
@ -159,7 +159,7 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
|
|||
|
||||
byGroup := map[string]Series{}
|
||||
for _, s := range res {
|
||||
require.Empty(t, s.Name)
|
||||
require.Empty(t, s.Title)
|
||||
require.Len(t, s.Data, 2)
|
||||
byGroup[s.Group] = s
|
||||
}
|
||||
|
|
|
|||
14
core/site.go
14
core/site.go
|
|
@ -136,7 +136,7 @@ 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)
|
||||
me, err := metrics.NewCollector(metrics.Home, metrics.Home, metrics.Home)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
return errors.New("missing grid meter instance")
|
||||
}
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef)
|
||||
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -204,7 +204,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
site.pvMeters = append(site.pvMeters, dev)
|
||||
|
||||
// energy collector (for history persistence and forecast scaling)
|
||||
me, err := metrics.NewCollector(metrics.PV, ref)
|
||||
me, err := metrics.NewCollector(metrics.PV, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -212,7 +212,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
|
||||
// solar forecast collector (mirrors PV history shape, used for scale lookup)
|
||||
fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast)
|
||||
fc, err := metrics.NewCollector(metrics.Forecast, metrics.Forecast, metrics.Forecast)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -226,7 +226,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
site.batteryMeters = append(site.batteryMeters, dev)
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Battery, ref)
|
||||
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
site.extMeters = append(site.extMeters, dev)
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Meter, ref)
|
||||
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -256,7 +256,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
site.auxMeters = append(site.auxMeters, dev)
|
||||
|
||||
me, err := metrics.NewCollector(metrics.Meter, ref)
|
||||
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -121,7 +122,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
minLen = min(minLen, len(solar))
|
||||
}
|
||||
|
||||
uri := lo.CoalesceOrEmpty(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI)
|
||||
uri := cmp.Or(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI)
|
||||
if uri == OPTIMIZER_URI {
|
||||
// limit to 2 days for sake of performance
|
||||
minLen = min(2*96, minLen)
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ test.describe("api", () => {
|
|||
const data = await res.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const grid = data.find((s: { name: string }) => s.name === "grid");
|
||||
const home = data.find((s: { name: string }) => s.name === "home");
|
||||
const grid = data.find((s: { title: string }) => s.title === "grid");
|
||||
const home = data.find((s: { title: string }) => s.title === "home");
|
||||
expect(grid).toBeDefined();
|
||||
expect(home).toBeDefined();
|
||||
|
||||
|
|
@ -46,8 +46,8 @@ test.describe("api", () => {
|
|||
const data = await res.json();
|
||||
expect(data).toHaveLength(2);
|
||||
|
||||
const grid = data.find((s: { name: string }) => s.name === "grid");
|
||||
const home = data.find((s: { name: string }) => s.name === "home");
|
||||
const grid = data.find((s: { title: string }) => s.title === "grid");
|
||||
const home = data.find((s: { title: string }) => s.title === "home");
|
||||
expect(grid).toBeDefined();
|
||||
expect(home).toBeDefined();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue