cli: add metrics command (#30099)
This commit is contained in:
parent
827149d4a3
commit
b41dc59531
11 changed files with 971 additions and 4 deletions
113
cmd/metrics.go
Normal file
113
cmd/metrics.go
Normal 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
118
cmd/metrics_battery.go
Normal 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()
|
||||
}
|
||||
64
cmd/metrics_battery_test.go
Normal file
64
cmd/metrics_battery_test.go
Normal 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
260
cmd/metrics_data.go
Normal 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
131
cmd/metrics_data_test.go
Normal 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
45
cmd/metrics_entities.go
Normal 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
82
cmd/metrics_forecast.go
Normal 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()
|
||||
}
|
||||
51
cmd/metrics_forecast_test.go
Normal file
51
cmd/metrics_forecast_test.go
Normal 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], "%")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue