Metrics: join energy series by entity name (#30991)
This commit is contained in:
parent
470082e7d7
commit
1582d869cc
6 changed files with 49 additions and 114 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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], "%")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue