Metrics: store entity title on lazy-create (#30196)

This commit is contained in:
andig 2026-06-07 13:11:20 +02:00 • committed by GitHub
parent 0fa8b97daf
commit a8361b575b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 209 additions and 142 deletions

View file

@ -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);
},
},
});

View file

@ -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;
},

View file

@ -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,
};
});
},

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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 ""
}

View file

@ -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},
}},
}

View file

@ -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 {

View file

@ -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

View file

@ -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)
}
return entity, nil
// updateTitle refreshes the entity's stored title if it changed
func (e *entity) updateTitle(title string) error {
if title == "" || e.Title == title {
return 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 {

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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();