evcc-io/cmd/metrics_data.go
2026-05-22 13:39:04 +02:00

260 lines
7.2 KiB
Go

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()
}