diff --git a/cmd/metrics.go b/cmd/metrics.go new file mode 100644 index 000000000..4b6172aeb --- /dev/null +++ b/cmd/metrics.go @@ -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-, 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 "" + } + } +} diff --git a/cmd/metrics_battery.go b/cmd/metrics_battery.go new file mode 100644 index 000000000..7f136ba66 --- /dev/null +++ b/cmd/metrics_battery.go @@ -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() +} diff --git a/cmd/metrics_battery_test.go b/cmd/metrics_battery_test.go new file mode 100644 index 000000000..bbc61d730 --- /dev/null +++ b/cmd/metrics_battery_test.go @@ -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], "%") +} diff --git a/cmd/metrics_data.go b/cmd/metrics_data.go new file mode 100644 index 000000000..270c705e8 --- /dev/null +++ b/cmd/metrics_data.go @@ -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() +} diff --git a/cmd/metrics_data_test.go b/cmd/metrics_data_test.go new file mode 100644 index 000000000..8df630378 --- /dev/null +++ b/cmd/metrics_data_test.go @@ -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") +} diff --git a/cmd/metrics_entities.go b/cmd/metrics_entities.go new file mode 100644 index 000000000..13f06b350 --- /dev/null +++ b/cmd/metrics_entities.go @@ -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() +} diff --git a/cmd/metrics_forecast.go b/cmd/metrics_forecast.go new file mode 100644 index 000000000..ad683c92d --- /dev/null +++ b/cmd/metrics_forecast.go @@ -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() +} diff --git a/cmd/metrics_forecast_test.go b/cmd/metrics_forecast_test.go new file mode 100644 index 000000000..da0e69284 --- /dev/null +++ b/cmd/metrics_forecast_test.go @@ -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], "%") +} diff --git a/core/metrics/db_entities.go b/core/metrics/db_entities.go new file mode 100644 index 000000000..34ad12614 --- /dev/null +++ b/core/metrics/db_entities.go @@ -0,0 +1,55 @@ +package metrics + +import ( + "database/sql" + "time" + + "github.com/evcc-io/evcc/server/db" +) + +// EntityInfo describes a metric entity and the extent of its stored data. +type EntityInfo struct { + Group string + Name string + 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 +} + +// ListEntities returns all metric entities together with their slot count and +// data range. Entities without any persisted slots are included with a zero +// slot count and zero First/Last timestamps. +func ListEntities() ([]EntityInfo, error) { + type row struct { + Group string + Name string + Slots int + First sql.NullInt64 + Last sql.NullInt64 + } + + var rows []row + if err := db.Instance.Table("entities e"). + Select(`e."group" AS "group", e.name AS name, + 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"). + Group("e.id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + res := make([]EntityInfo, 0, len(rows)) + for _, r := range rows { + e := EntityInfo{Group: r.Group, Name: r.Name, Slots: r.Slots} + if r.First.Valid { + e.First = time.Unix(r.First.Int64, 0) + } + if r.Last.Valid { + e.Last = time.Unix(r.Last.Int64, 0) + } + res = append(res, e) + } + + return res, nil +} diff --git a/core/metrics/db_entities_test.go b/core/metrics/db_entities_test.go new file mode 100644 index 000000000..660ad64ce --- /dev/null +++ b/core/metrics/db_entities_test.go @@ -0,0 +1,47 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +func TestListEntities(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + // SetupSchema reserves the home entity (id 1) without data + grid := entity{Id: 2, Name: "grid", Group: Grid} + require.NoError(t, db.Instance.Create(&grid).Error) + pv := entity{Id: 3, Name: "pv1", Group: PV} + require.NoError(t, db.Instance.Create(&pv).Error) + + base := time.Date(2026, 4, 15, 16, 0, 0, 0, time.Now().Location()) + require.NoError(t, persist(grid, base, 1, 0)) + require.NoError(t, persist(grid, base.Add(time.Hour), 2, 0)) + + entities, err := ListEntities() + require.NoError(t, err) + require.Len(t, entities, 3) // home + grid + pv1 + + byName := make(map[string]EntityInfo, len(entities)) + for _, e := range entities { + byName[e.Name] = e + } + + // entity with data: slot count and range + require.Equal(t, Grid, byName["grid"].Group) + require.Equal(t, 2, byName["grid"].Slots) + require.True(t, base.Equal(byName["grid"].First)) + require.True(t, base.Add(time.Hour).Equal(byName["grid"].Last)) + + // entity without data: zero slots, zero timestamps + require.Equal(t, 0, byName["pv1"].Slots) + require.True(t, byName["pv1"].First.IsZero()) + require.True(t, byName["pv1"].Last.IsZero()) + + // home entity exists but has no data + require.Equal(t, 0, byName["home"].Slots) +} diff --git a/core/metrics/db_history.go b/core/metrics/db_history.go index de15951ae..7f31df1e2 100644 --- a/core/metrics/db_history.go +++ b/core/metrics/db_history.go @@ -37,8 +37,9 @@ type Series struct { // SeriesCSV wraps a slice of Series for CSV export. type SeriesCSV []Series -// csvGroupOrder mirrors the frontend GROUP_ORDER plus home/forecast. -var csvGroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast} +// GroupOrder is the canonical display order of metric groups, mirroring the +// frontend GROUP_ORDER plus home/forecast. +var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Meter, Home, Forecast} var aggregateFormats = map[string]string{ "15m": "%Y-%m-%d %H:%M", @@ -147,8 +148,8 @@ func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error { byGroup[g] = append(byGroup[g], &s[i]) } - rank := make(map[string]int, len(csvGroupOrder)) - for i, g := range csvGroupOrder { + rank := make(map[string]int, len(GroupOrder)) + for i, g := range GroupOrder { rank[g] = i } groups := make([]string, 0, len(byGroup))