cli: add metrics command (#30099)

This commit is contained in:
andig 2026-05-22 13:39:04 +02:00 • committed by GitHub
parent 827149d4a3
commit b41dc59531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 971 additions and 4 deletions

113
cmd/metrics.go Normal file
View file

@ -0,0 +1,113 @@
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"
)
// metricsCmd represents the metrics command
var metricsCmd = &cobra.Command{
Use: "metrics",
Short: "Inspect stored energy metrics",
}
func init() {
rootCmd.AddCommand(metricsCmd)
}
// setupMetrics loads the config file and opens the database. Both metrics
// subcommands need the metric tables and the device configuration.
func setupMetrics(cmd *cobra.Command) {
if err := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); err != nil {
log.FATAL.Fatal(err)
}
if err := configureDatabase(conf.Database); err != nil {
log.FATAL.Fatal(err)
}
}
// metricsGroupRank returns the canonical sort position of a metric group.
func metricsGroupRank(group string) int {
if i := slices.Index(metrics.GroupOrder, group); i >= 0 {
return i
}
return len(metrics.GroupOrder)
}
// metricsSortCanonical sorts entities by canonical group order, then name.
func metricsSortCanonical(entities []metrics.EntityInfo) {
slices.SortFunc(entities, func(a, b metrics.EntityInfo) int {
if d := metricsGroupRank(a.Group) - metricsGroupRank(b.Group); d != 0 {
return d
}
return strings.Compare(a.Name, b.Name)
})
}
// metricsFormatDate formats a date for display, empty for the zero time.
func metricsFormatDate(t time.Time) string {
if t.IsZero() {
return ""
}
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 ""
}
}
}

118
cmd/metrics_battery.go Normal file
View file

@ -0,0 +1,118 @@
package cmd
import (
"fmt"
"io"
"os"
"text/tabwriter"
"github.com/evcc-io/evcc/core/metrics"
"github.com/spf13/cobra"
)
// metricsBatteryCmd represents the metrics battery command
var metricsBatteryCmd = &cobra.Command{
Use: "battery [name ...]",
Short: "Compare battery charge and discharge energy",
Long: `Compare charge and discharge energy per battery.
Without arguments all batteries are compared for the current day. Batteries can
be selected by name or title.`,
Run: runMetricsBattery,
}
func init() {
metricsCmd.AddCommand(metricsBatteryCmd)
metricsBatteryCmd.Flags().String("range", "", "Quick timeframe: day, month or year")
metricsBatteryCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)")
metricsBatteryCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)")
metricsBatteryCmd.MarkFlagsMutuallyExclusive("range", "from")
metricsBatteryCmd.MarkFlagsMutuallyExclusive("range", "to")
}
func runMetricsBattery(cmd *cobra.Command, args []string) {
setupMetrics(cmd)
from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String())
if err != nil {
log.FATAL.Fatal(err)
}
entities, err := metrics.ListEntities()
if err != nil {
log.FATAL.Fatal(err)
}
// limit selectable entities to the battery group
batteries := make([]metrics.EntityInfo, 0, len(entities))
for _, e := range entities {
if e.Group == metrics.Battery {
batteries = append(batteries, e)
}
}
if len(batteries) == 0 {
log.FATAL.Fatal("no battery entities found")
}
title := metricsEntityTitle()
selected, err := metricsSelectEntities(batteries, args, "", title)
if err != nil {
log.FATAL.Fatal(err)
}
series, err := metrics.QueryEnergy(from, to, "month", false)
if err != nil {
log.FATAL.Fatal(err)
}
metricsWriteBatteryTable(os.Stdout, selected, metricsBatteryTotals(series), title)
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
}
// batteryTotals holds the accumulated charge and discharge energy of a battery.
// For a battery entity charge is stored as import energy, discharge as export
// energy (see core/site.go updateBatteryMeters).
type batteryTotals struct {
charge float64
discharge float64
}
// metricsBatteryTotals sums charge and discharge energy per battery entity.
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]
for _, slot := range s.Data {
t.charge += slot.Energy
t.discharge += slot.ReturnEnergy
}
res[s.Name] = t
}
return res
}
// 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) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "name\ttitle\tcharge\tdischarge\tefficiency")
for _, e := range selected {
t := totals[e.Name]
efficiency := ""
if t.charge > 0 {
efficiency = fmt.Sprintf("%.1f%%", t.discharge/t.charge*100)
}
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)
}
tw.Flush()
}

View file

@ -0,0 +1,64 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/evcc-io/evcc/core/metrics"
"github.com/stretchr/testify/require"
)
func TestMetricsBatteryTotals(t *testing.T) {
series := []metrics.Series{
{Group: metrics.Battery, Name: "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{
{Energy: 5.0, ReturnEnergy: 3.0},
}},
}
totals := metricsBatteryTotals(series)
require.Len(t, totals, 1)
require.InDelta(t, 3.0, totals["bat"].charge, 0.001)
require.InDelta(t, 2.0, totals["bat"].discharge, 0.001)
}
func TestMetricsWriteBatteryTable(t *testing.T) {
selected := []metrics.EntityInfo{
{Group: metrics.Battery, Name: "bat1"},
{Group: metrics.Battery, Name: "bat2"},
}
totals := map[string]batteryTotals{
"bat1": {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 ""
}
var buf bytes.Buffer
metricsWriteBatteryTable(&buf, selected, totals, title)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3) // header + 2 rows
require.Contains(t, lines[0], "efficiency")
// bat1: title resolved, 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], "%")
}

260
cmd/metrics_data.go Normal file
View file

@ -0,0 +1,260 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/spf13/cobra"
)
// metricsDataCmd represents the metrics data command
var metricsDataCmd = &cobra.Command{
Use: "data [entity ...]",
Short: "Export energy data as a table",
Long: `Export aggregated energy data as a table.
Without arguments all entities are exported for the current day. Entities can be
selected by name or title; run the entities subcommand to list them.`,
Run: runMetricsData,
}
func init() {
metricsCmd.AddCommand(metricsDataCmd)
metricsDataCmd.Flags().String("range", "", "Quick timeframe: day, month or year")
metricsDataCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)")
metricsDataCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)")
metricsDataCmd.Flags().String("aggregate", "hour", "Aggregation interval: 15m, hour, day or month")
metricsDataCmd.Flags().String("group", "", "Limit output to an entity group")
metricsDataCmd.Flags().Bool("csv", false, "Output CSV instead of a table")
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "from")
metricsDataCmd.MarkFlagsMutuallyExclusive("range", "to")
}
func runMetricsData(cmd *cobra.Command, args []string) {
setupMetrics(cmd)
group := cmd.Flag("group").Value.String()
if group != "" && len(args) > 0 {
log.FATAL.Fatal("--group and entity arguments are mutually exclusive")
}
from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String())
if err != nil {
log.FATAL.Fatal(err)
}
aggregate := cmd.Flag("aggregate").Value.String()
entities, err := metrics.ListEntities()
if err != nil {
log.FATAL.Fatal(err)
}
title := metricsEntityTitle()
selected, err := metricsSelectEntities(entities, args, group, title)
if err != nil {
log.FATAL.Fatal(err)
}
series, err := metrics.QueryEnergy(from, to, aggregate, false)
if err != nil {
log.FATAL.Fatal(err)
}
byEntity := make(map[string]metrics.Series, len(series))
for _, s := range series {
byEntity[s.Group+"/"+s.Name] = s
}
if asCSV, _ := cmd.Flags().GetBool("csv"); asCSV {
var out metrics.SeriesCSV
for _, e := range selected {
if s, ok := byEntity[e.Group+"/"+e.Name]; ok {
out = append(out, s)
}
}
if err := out.WriteCsv(context.Background(), os.Stdout); err != nil {
log.FATAL.Fatal(err)
}
return
}
metricsWriteTable(os.Stdout, selected, byEntity, title, aggregate)
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
}
// metricsTimeframe resolves the from/to query bounds. A non-empty range string
// (today, month, year) takes precedence and is mutually exclusive with the
// from/to date flags. The to date is inclusive; an empty timeframe defaults to
// the current day.
func metricsTimeframe(rangeStr, fromStr, toStr string) (time.Time, time.Time, error) {
const layout = "2006-01-02"
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
if rangeStr != "" {
switch strings.ToLower(rangeStr) {
case "day":
return today, today.AddDate(0, 0, 1), nil
case "month":
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
return from, from.AddDate(0, 1, 0), nil
case "year":
from := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local)
return from, from.AddDate(1, 0, 0), nil
default:
return time.Time{}, time.Time{}, fmt.Errorf("invalid --range %q (day, month or year)", rangeStr)
}
}
from := today
if fromStr != "" {
t, err := time.ParseInLocation(layout, fromStr, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid --from date: %w", err)
}
from = t
}
to := today.AddDate(0, 0, 1)
if toStr != "" {
t, err := time.ParseInLocation(layout, toStr, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid --to date: %w", err)
}
to = t.AddDate(0, 0, 1) // inclusive end day
}
if to.Before(from) {
return time.Time{}, time.Time{}, errors.New("--to must not be before --from")
}
return from, to, nil
}
// 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) {
if len(args) == 0 {
res := make([]metrics.EntityInfo, 0, len(entities))
for _, e := range entities {
if group == "" || e.Group == group {
res = append(res, e)
}
}
if group != "" && len(res) == 0 {
return nil, fmt.Errorf("no entities in group %q", group)
}
metricsSortCanonical(res)
return res, nil
}
var res []metrics.EntityInfo
for _, arg := range args {
var matched []metrics.EntityInfo
for _, e := range entities {
if e.Name == arg || title(e.Group, e.Name) == arg {
matched = append(matched, e)
}
}
if len(matched) == 0 {
return nil, fmt.Errorf("unknown entity %q", arg)
}
res = append(res, matched...)
}
return res, nil
}
// metricsTimeLayout returns the time column format for the given aggregation.
func metricsTimeLayout(aggregate string) string {
switch aggregate {
case "day":
return "2006-01-02"
case "month":
return "2006-01"
default: // 15m, hour
return "2006-01-02 15:04"
}
}
// 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) {
layout := metricsTimeLayout(aggregate)
type colSpec struct {
entity metrics.EntityInfo
energyCol int
returnCol int // -1 unless the entity is bidirectional
}
header := []string{"time"}
var specs []colSpec
for _, e := range selected {
label := title(e.Group, e.Name)
if label == "" {
label = e.Name
}
spec := colSpec{entity: e, energyCol: len(header) - 1, returnCol: -1}
header = append(header, label)
if e.Group == metrics.Grid || e.Group == metrics.Battery {
spec.returnCol = len(header) - 1
header = append(header, label+"↑")
}
specs = append(specs, spec)
}
ncols := len(header) - 1
rowByKey := make(map[string][]string)
var rowKeys []string
cells := func(key string) []string {
if c, ok := rowByKey[key]; ok {
return c
}
c := make([]string, ncols)
rowByKey[key] = c
rowKeys = append(rowKeys, key)
return c
}
for _, spec := range specs {
s, ok := byEntity[spec.entity.Group+"/"+spec.entity.Name]
if !ok {
continue
}
for _, slot := range s.Data {
row := cells(slot.Start.Local().Format(layout))
row[spec.energyCol] = fmt.Sprintf("%.3f", slot.Energy)
if spec.returnCol >= 0 {
row[spec.returnCol] = fmt.Sprintf("%.3f", slot.ReturnEnergy)
}
}
}
slices.Sort(rowKeys)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(header, "\t"))
for _, key := range rowKeys {
fmt.Fprintln(tw, key+"\t"+strings.Join(rowByKey[key], "\t"))
}
tw.Flush()
}

131
cmd/metrics_data_test.go Normal file
View file

@ -0,0 +1,131 @@
package cmd
import (
"bytes"
"strings"
"testing"
"time"
"github.com/evcc-io/evcc/core/metrics"
"github.com/stretchr/testify/require"
)
func TestMetricsTimeframe(t *testing.T) {
// explicit range: to is inclusive, so it extends to the end of the named day
from, to, err := metricsTimeframe("", "2026-05-01", "2026-05-03")
require.NoError(t, err)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, time.Local), from)
require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.Local), to)
// to before from
_, _, err = metricsTimeframe("", "2026-05-05", "2026-05-01")
require.Error(t, err)
// invalid date
_, _, err = metricsTimeframe("", "notadate", "")
require.Error(t, err)
}
func TestMetricsTimeframeRange(t *testing.T) {
now := time.Now()
// day
from, to, err := metricsTimeframe("day", "", "")
require.NoError(t, err)
require.Equal(t, time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local), from)
require.Equal(t, from.AddDate(0, 0, 1), to)
// month, case-insensitive
from, to, err = metricsTimeframe("Month", "", "")
require.NoError(t, err)
require.Equal(t, time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local), from)
require.Equal(t, from.AddDate(0, 1, 0), to)
// year
from, to, err = metricsTimeframe("year", "", "")
require.NoError(t, err)
require.Equal(t, time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.Local), from)
require.Equal(t, from.AddDate(1, 0, 0), to)
// invalid range value
_, _, err = metricsTimeframe("decade", "", "")
require.Error(t, err)
}
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 ""
}
// explicit selectors match by name or title and preserve argument order
res, err := metricsSelectEntities(entities, []string{"Carport", "grid"}, "", title)
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)
require.Error(t, err)
// no selectors: all entities in canonical group order (pv before grid)
res, err = metricsSelectEntities(entities, nil, "", title)
require.NoError(t, err)
require.Equal(t, metrics.PV, res[0].Group)
// empty group errors
_, err = metricsSelectEntities(entities, nil, metrics.Battery, title)
require.Error(t, err)
}
func TestMetricsWriteTable(t *testing.T) {
h0 := time.Date(2026, 5, 22, 0, 0, 0, 0, time.Local)
h1 := h0.Add(time.Hour)
selected := []metrics.EntityInfo{
{Group: metrics.Loadpoint, Name: "lp-1"},
{Group: metrics.Grid, Name: "grid"},
}
byEntity := map[string]metrics.Series{
metrics.Loadpoint + "/lp-1": {Group: metrics.Loadpoint, Name: "lp-1", Data: []metrics.Slot{
{Start: h0, Energy: 1.84},
}},
metrics.Grid + "/grid": {Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{
{Start: h0, Energy: 0.412},
{Start: h1, Energy: 0.38, ReturnEnergy: 0.05},
}},
}
title := func(group, name string) string {
if group == metrics.Loadpoint {
return "Carport"
}
return ""
}
var buf bytes.Buffer
metricsWriteTable(&buf, selected, byEntity, title, "hour")
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 3) // header + 2 rows
// header: title falls back to name; bidirectional grid gets a return column
require.Contains(t, lines[0], "Carport")
require.Contains(t, lines[0], "grid↑")
// row 1: both lp-1 and grid have data
require.Contains(t, lines[1], "2026-05-22 00:00")
require.Contains(t, lines[1], "1.840")
require.Contains(t, lines[1], "0.412")
// row 2: lp-1 has no slot -> blank cell, not 0.000; grid return energy shown
require.Contains(t, lines[2], "2026-05-22 01:00")
require.Contains(t, lines[2], "0.380")
require.Contains(t, lines[2], "0.050")
require.NotContains(t, lines[2], "0.000")
}

45
cmd/metrics_entities.go Normal file
View file

@ -0,0 +1,45 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/evcc-io/evcc/core/metrics"
"github.com/spf13/cobra"
)
// metricsEntitiesCmd represents the metrics entities command
var metricsEntitiesCmd = &cobra.Command{
Use: "entities",
Short: "List metric entities",
Args: cobra.NoArgs,
Run: runMetricsEntities,
}
func init() {
metricsCmd.AddCommand(metricsEntitiesCmd)
}
func runMetricsEntities(cmd *cobra.Command, args []string) {
setupMetrics(cmd)
entities, err := metrics.ListEntities()
if err != nil {
log.FATAL.Fatal(err)
}
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,
metricsFormatDate(e.First), metricsFormatDate(e.Last))
}
tw.Flush()
}

82
cmd/metrics_forecast.go Normal file
View file

@ -0,0 +1,82 @@
package cmd
import (
"fmt"
"io"
"os"
"text/tabwriter"
"github.com/evcc-io/evcc/core/metrics"
"github.com/spf13/cobra"
)
// metricsForecastCmd represents the metrics forecast command
var metricsForecastCmd = &cobra.Command{
Use: "forecast",
Short: "Compare solar forecast against actual PV production",
Args: cobra.NoArgs,
Run: runMetricsForecast,
}
func init() {
metricsCmd.AddCommand(metricsForecastCmd)
metricsForecastCmd.Flags().String("range", "", "Quick timeframe: day, month or year")
metricsForecastCmd.Flags().String("from", "", "Start date as YYYY-MM-DD (default today)")
metricsForecastCmd.Flags().String("to", "", "End date as YYYY-MM-DD, inclusive (default today)")
metricsForecastCmd.MarkFlagsMutuallyExclusive("range", "from")
metricsForecastCmd.MarkFlagsMutuallyExclusive("range", "to")
}
func runMetricsForecast(cmd *cobra.Command, args []string) {
setupMetrics(cmd)
from, to, err := metricsTimeframe(cmd.Flag("range").Value.String(), cmd.Flag("from").Value.String(), cmd.Flag("to").Value.String())
if err != nil {
log.FATAL.Fatal(err)
}
series, err := metrics.QueryEnergy(from, to, "month", false)
if err != nil {
log.FATAL.Fatal(err)
}
forecast, actual := metricsForecastTotals(series)
metricsWriteForecastTable(os.Stdout, forecast, actual)
fmt.Fprintln(os.Stderr, "\nvalues in kWh")
}
// metricsForecastTotals sums forecasted solar energy and actual PV production
// over the given series.
func metricsForecastTotals(series []metrics.Series) (forecast, actual float64) {
for _, s := range series {
var sum float64
for _, slot := range s.Data {
sum += slot.Energy
}
switch s.Group {
case metrics.Forecast:
forecast += sum
case metrics.PV:
actual += sum
}
}
return forecast, actual
}
// metricsWriteForecastTable renders a one-row comparison of forecasted versus
// actual solar energy. Accuracy is the actual/forecast ratio, left blank when
// nothing was forecast.
func metricsWriteForecastTable(w io.Writer, forecast, actual float64) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "forecast\tactual\taccuracy")
accuracy := ""
if forecast > 0 {
accuracy = fmt.Sprintf("%.1f%%", actual/forecast*100)
}
fmt.Fprintf(tw, "%.3f\t%.3f\t%s\n", forecast, actual, accuracy)
tw.Flush()
}

View file

@ -0,0 +1,51 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/evcc-io/evcc/core/metrics"
"github.com/stretchr/testify/require"
)
func TestMetricsForecastTotals(t *testing.T) {
series := []metrics.Series{
{Group: metrics.Forecast, Name: "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{
{Energy: 4.0}, {Energy: 3.0},
}},
{Group: metrics.PV, Name: "pv2", Data: []metrics.Slot{
{Energy: 2.0},
}},
// other groups are ignored
{Group: metrics.Grid, Name: "grid", Data: []metrics.Slot{
{Energy: 99.0},
}},
}
forecast, actual := metricsForecastTotals(series)
require.InDelta(t, 15.0, forecast, 0.001)
require.InDelta(t, 9.0, actual, 0.001)
}
func TestMetricsWriteForecastTable(t *testing.T) {
var buf bytes.Buffer
metricsWriteForecastTable(&buf, 100.0, 90.0)
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.Len(t, lines, 2) // header + 1 row
require.Contains(t, lines[0], "accuracy")
require.Contains(t, lines[1], "100.000")
require.Contains(t, lines[1], "90.000")
require.Contains(t, lines[1], "90.0%")
// zero forecast -> blank accuracy
buf.Reset()
metricsWriteForecastTable(&buf, 0, 0)
lines = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
require.NotContains(t, lines[1], "%")
}