Metrics: join energy series by entity name (#30991)

This commit is contained in:
andig 2026-06-18 12:05:09 +02:00 • committed by GitHub
parent 470082e7d7
commit 1582d869cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 49 additions and 114 deletions

View file

@ -2,13 +2,10 @@ package cmd
import (
"slices"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/spf13/cobra"
)
@ -60,54 +57,11 @@ func metricsFormatDate(t time.Time) string {
return t.Local().Format("2006-01-02")
}
// metricsEntityTitle resolves human-readable titles for metric entities. Titles
// exist only for configured loadpoints and meters; virtual entities (home,
// forecast) have none. The returned function maps an entity to its title, or an
// empty string when no title is configured.
func metricsEntityTitle() func(group, name string) string {
// loadpoints are addressed as lp-<n>, numbered yaml-first then database,
// mirroring configureLoadpoints
loadpoints := make(map[string]string)
idx := 0
addLoadpoint := func(n config.Named) {
idx++
if t, ok := n.Property("title").(string); ok && t != "" {
loadpoints["lp-"+strconv.Itoa(idx)] = t
}
}
for _, lp := range conf.Loadpoints {
addLoadpoint(lp)
}
if devices, err := config.ConfigurationsByClass(templates.Loadpoint); err == nil {
for _, dev := range devices {
addLoadpoint(dev.Named())
}
}
// meter entities are addressed by their device ref; the title comes from the
// device configuration
meters := make(map[string]string)
for _, m := range conf.Meters {
if t, ok := m.Property("title").(string); ok && t != "" {
meters[m.Name] = t
}
}
if devices, err := config.ConfigurationsByClass(templates.Meter); err == nil {
for _, dev := range devices {
if dev.Title != "" {
meters[config.NameForID(dev.ID)] = dev.Title
}
}
}
return func(group, name string) string {
switch group {
case metrics.Loadpoint:
return loadpoints[name]
case metrics.Grid, metrics.PV, metrics.Battery, metrics.Meter:
return meters[name]
default:
return ""
}
// metricsEntityLabel returns the display label for an entity: the title stored
// in the entities table (the single source of truth), or the name as fallback.
func metricsEntityLabel(e metrics.EntityInfo) string {
if e.Title != "" {
return e.Title
}
return e.Name
}

View file

@ -54,9 +54,7 @@ func runMetricsBattery(cmd *cobra.Command, args []string) {
log.FATAL.Fatal("no battery entities found")
}
title := metricsEntityTitle()
selected, err := metricsSelectEntities(batteries, args, "", title)
selected, err := metricsSelectEntities(batteries, args, "")
if err != nil {
log.FATAL.Fatal(err)
}
@ -66,7 +64,7 @@ func runMetricsBattery(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err)
}
metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series), title)
metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series))
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
}
@ -98,12 +96,12 @@ func metricsBatteryTotals(series []metrics.Series) map[string]batteryTotals {
// metricsWriteBatteryTable renders one row per battery comparing charge and
// discharge energy. Efficiency is the discharge/charge ratio, left blank when
// no energy was charged.
func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals map[string]batteryTotals, title func(group, name string) string) {
func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals map[string]batteryTotals) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency")
for _, e := range selected {
t := totals[title(e.Group, e.Name)]
t := totals[metricsEntityLabel(e)]
efficiency := ""
if t.charge > 0 {
@ -111,7 +109,7 @@ func metricsWriteBatteryTable(w io.Writer, selected []metrics.EntityInfo, totals
}
fmt.Fprintf(tw, "%s\t%s\t%.3f\t%.3f\t%s\n",
e.Name, title(e.Group, e.Name), t.charge, t.discharge, efficiency)
e.Name, metricsEntityLabel(e), t.charge, t.discharge, efficiency)
}
tw.Flush()

View file

@ -29,36 +29,40 @@ func TestMetricsBatteryTotals(t *testing.T) {
func TestMetricsWriteBatteryTable(t *testing.T) {
selected := []metrics.EntityInfo{
{Group: metrics.Battery, Name: "bat1"},
{Group: metrics.Battery, Name: "bat2"},
{Group: metrics.Battery, Name: "db:1", Title: "Home"},
{Group: metrics.Battery, Name: "db:2", Title: "Hyper2000"}, // removed device: stored db title remains
{Group: metrics.Battery, Name: "db:3"},
}
totals := map[string]batteryTotals{
"Home": {charge: 10.0, discharge: 9.0},
// bat2 deliberately absent: no data in the timeframe
}
title := func(group, name string) string {
if name == "bat1" {
return "Home"
}
return ""
"Home": {charge: 10.0, discharge: 9.0},
"Hyper2000": {charge: 4.0, discharge: 3.0},
// db:3 deliberately absent: no data in the timeframe
}
var buf bytes.Buffer
metricsWriteBatteryTable(&buf, selected, totals, title)
metricsWriteBatteryTable(&buf, selected, totals)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3) // header + 2 rows
require.Len(t, lines, 4) // header + 3 rows
require.Contains(t, lines[0], "efficiency")
// bat1: title resolved, efficiency = discharge/charge
// db:1: stored title, efficiency = discharge/charge
require.Contains(t, lines[1], "Home")
require.Contains(t, lines[1], "10.000")
require.Contains(t, lines[1], "9.000")
require.Contains(t, lines[1], "90.0%")
// bat2: no data -> zero totals, blank efficiency
require.Contains(t, lines[2], "bat2")
require.Contains(t, lines[2], "0.000")
require.NotContains(t, lines[2], "%")
// db:2: removed device still joins via its stored title; label is that title
require.Contains(t, lines[2], "Hyper2000")
require.Contains(t, lines[2], "4.000")
require.Contains(t, lines[2], "3.000")
require.Contains(t, lines[2], "75.0%")
// db:3: no title anywhere -> label falls back to the name; no data -> blank efficiency
f := strings.Fields(lines[3])
require.Equal(t, "db:3", f[0]) // name column
require.Equal(t, "db:3", f[1]) // title column falls back to name
require.Contains(t, lines[3], "0.000")
require.NotContains(t, lines[3], "%")
}

View file

@ -58,9 +58,7 @@ func runMetricsData(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err)
}
title := metricsEntityTitle()
selected, err := metricsSelectEntities(entities, args, group, title)
selected, err := metricsSelectEntities(entities, args, group)
if err != nil {
log.FATAL.Fatal(err)
}
@ -79,7 +77,7 @@ func runMetricsData(cmd *cobra.Command, args []string) {
var out metrics.SeriesCSV
seen := make(map[string]bool, len(selected))
for _, e := range selected {
key := e.Group + "/" + title(e.Group, e.Name)
key := e.Group + "/" + metricsEntityLabel(e)
if seen[key] {
continue
}
@ -94,7 +92,7 @@ func runMetricsData(cmd *cobra.Command, args []string) {
return
}
metricsWriteTable(os.Stdout, selected, byEntity, title, aggregate)
metricsWriteTable(os.Stdout, selected, byEntity, aggregate)
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
}
@ -151,7 +149,7 @@ func metricsTimeframe(rangeStr, fromStr, toStr string) (time.Time, time.Time, er
// metricsSelectEntities resolves the entities to export. Without selectors all
// entities (optionally limited to a group) are returned in canonical order;
// explicit selectors match by name or title and preserve the requested order.
func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group string, title func(group, name string) string) ([]metrics.EntityInfo, error) {
func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group string) ([]metrics.EntityInfo, error) {
if len(args) == 0 {
res := make([]metrics.EntityInfo, 0, len(entities))
for _, e := range entities {
@ -170,7 +168,7 @@ func metricsSelectEntities(entities []metrics.EntityInfo, args []string, group s
for _, arg := range args {
var matched []metrics.EntityInfo
for _, e := range entities {
if e.Name == arg || title(e.Group, e.Name) == arg {
if e.Name == arg || e.Title == arg {
matched = append(matched, e)
}
}
@ -197,7 +195,7 @@ func metricsTimeLayout(aggregate string) string {
// metricsWriteTable renders the wide energy table: one row per time slot, one
// column per entity, plus a second column for the export energy of
// bidirectional entities (grid, battery).
func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[string]metrics.Series, title func(group, name string) string, aggregate string) {
func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[string]metrics.Series, aggregate string) {
layout := metricsTimeLayout(aggregate)
type colSpec struct {
@ -210,10 +208,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[
var specs []colSpec
for _, e := range selected {
label := title(e.Group, e.Name)
if label == "" {
label = e.Name
}
label := metricsEntityLabel(e)
spec := colSpec{entity: e, energyCol: len(header) - 1, returnCol: -1}
header = append(header, label)
@ -242,7 +237,7 @@ func metricsWriteTable(w io.Writer, selected []metrics.EntityInfo, byEntity map[
}
for _, spec := range specs {
s, ok := byEntity[spec.entity.Group+"/"+title(spec.entity.Group, spec.entity.Name)]
s, ok := byEntity[spec.entity.Group+"/"+metricsEntityLabel(spec.entity)]
if !ok {
continue
}

View file

@ -56,31 +56,25 @@ func TestMetricsSelectEntities(t *testing.T) {
entities := []metrics.EntityInfo{
{Group: metrics.Grid, Name: "grid"},
{Group: metrics.PV, Name: "pv1"},
{Group: metrics.Loadpoint, Name: "lp-1"},
}
title := func(group, name string) string {
if group == metrics.Loadpoint && name == "lp-1" {
return "Carport"
}
return ""
{Group: metrics.Loadpoint, Name: "lp-1", Title: "Carport"},
}
// explicit selectors match by name or title and preserve argument order
res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "", title)
// explicit selectors match by name or stored title and preserve argument order
res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "")
require.NoError(t, err)
require.Equal(t, []string{"lp-1", "grid"}, []string{res[0].Name, res[1].Name})
// unknown selector errors
_, err = metricsSelectEntities(entities, []string{"bogus"}, "", title)
_, err = metricsSelectEntities(entities, []string{"bogus"}, "")
require.Error(t, err)
// no selectors: all entities in canonical group order (pv before grid)
res, err = metricsSelectEntities(entities, nil, "", title)
res, err = metricsSelectEntities(entities, nil, "")
require.NoError(t, err)
require.Equal(t, metrics.PV, res[0].Group)
// empty group errors
_, err = metricsSelectEntities(entities, nil, metrics.Battery, title)
_, err = metricsSelectEntities(entities, nil, metrics.Battery)
require.Error(t, err)
}
@ -89,7 +83,7 @@ func TestMetricsWriteTable(t *testing.T) {
h1 := h0.Add(time.Hour)
selected := []metrics.EntityInfo{
{Group: metrics.Loadpoint, Name: "lp-1"},
{Group: metrics.Loadpoint, Name: "lp-1", Title: "Carport"},
{Group: metrics.Grid, Name: "grid"},
}
byEntity := map[string]metrics.Series{
@ -101,18 +95,9 @@ func TestMetricsWriteTable(t *testing.T) {
{Start: h1, Energy: 0.38, ReturnEnergy: 0.05},
}},
}
title := func(group, name string) string {
if group == metrics.Loadpoint {
return "Carport"
}
if group == metrics.Grid {
return "grid"
}
return ""
}
var buf bytes.Buffer
metricsWriteTable(&buf, selected, byEntity, title, "hour")
metricsWriteTable(&buf, selected, byEntity, "hour")
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3) // header + 2 rows

View file

@ -30,14 +30,13 @@ func runMetricsEntities(cmd *cobra.Command, args []string) {
}
metricsSortCanonical(entities)
title := metricsEntityTitle()
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "group\tname\ttitle\tslots\tfirst\tlast")
for _, e := range entities {
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\n",
e.Group, e.Name, title(e.Group, e.Name), e.Slots,
e.Group, e.Name, e.Title, e.Slots,
metricsFormatDate(e.First), metricsFormatDate(e.Last))
}