270 lines
6.7 KiB
Go
270 lines
6.7 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/evcc-io/evcc/server/db"
|
|
csvutil "github.com/evcc-io/evcc/util/csv"
|
|
)
|
|
|
|
// Slot represents an aggregated energy time slot
|
|
type Slot struct {
|
|
Start time.Time `json:"start"`
|
|
End time.Time `json:"end"`
|
|
Energy float64 `json:"energy"`
|
|
ReturnEnergy float64 `json:"returnEnergy"`
|
|
}
|
|
|
|
// roundEnergy rounds kWh to Wh precision and clamps negative noise to zero.
|
|
func roundEnergy(v float64) float64 {
|
|
return max(0, math.Round(v*1000)/1000)
|
|
}
|
|
|
|
// Series represents an energy series for one title group or one entity group.
|
|
type Series struct {
|
|
Title string `json:"title,omitempty"`
|
|
Group string `json:"group"`
|
|
Data []Slot `json:"data"`
|
|
}
|
|
|
|
// SeriesCSV wraps a slice of Series for CSV export.
|
|
type SeriesCSV []Series
|
|
|
|
// GroupOrder is the canonical display order of metric groups, mirroring the
|
|
// frontend GROUP_ORDER plus home/forecast.
|
|
var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast}
|
|
|
|
var aggregateFormats = map[string]string{
|
|
"15m": "%Y-%m-%d %H:%M",
|
|
"hour": "%Y-%m-%d %H:00",
|
|
"day": "%Y-%m-%d",
|
|
"month": "%Y-%m",
|
|
}
|
|
|
|
var aggregateDurations = map[string]func(time.Time) time.Time{
|
|
"15m": func(t time.Time) time.Time { return t.Add(15 * time.Minute) },
|
|
"hour": func(t time.Time) time.Time { return t.Add(time.Hour) },
|
|
"day": func(t time.Time) time.Time { return t.AddDate(0, 0, 1) },
|
|
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
|
|
}
|
|
|
|
// QueryEnergy returns aggregated energy data, per title or per group.
|
|
func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) {
|
|
addDuration := aggregateDurations[aggregate]
|
|
|
|
format, ok := aggregateFormats[aggregate]
|
|
if !ok {
|
|
return nil, errors.New("invalid aggregate value")
|
|
}
|
|
|
|
titleExpr := `COALESCE(NULLIF(e.title,''), e.name)`
|
|
timeCol := fmt.Sprintf(`strftime('%s', m.ts, 'unixepoch', 'localtime')`, format)
|
|
|
|
selectTitle := titleExpr + ` AS title`
|
|
groupCols := titleExpr + `, e."group", ` + timeCol
|
|
if grouped {
|
|
selectTitle = `'' AS title`
|
|
groupCols = `e."group", ` + timeCol
|
|
}
|
|
|
|
type row struct {
|
|
Title string
|
|
Group string
|
|
Start SqlTime
|
|
Energy float64
|
|
ReturnEnergy float64
|
|
}
|
|
|
|
tx := db.Instance.Table("meters m").
|
|
Select(selectTitle + `, e."group",
|
|
MIN(m.ts) AS start,
|
|
COALESCE(SUM(m.energy), 0) AS energy,
|
|
COALESCE(SUM(m.return_energy), 0) AS return_energy`).
|
|
Joins("JOIN entities e ON m.meter = e.id").
|
|
Group(groupCols).
|
|
Order(groupCols)
|
|
|
|
if !from.IsZero() {
|
|
tx = tx.Where("m.ts >= ?", from.Unix())
|
|
}
|
|
if !to.IsZero() {
|
|
tx = tx.Where("m.ts < ?", to.Unix())
|
|
}
|
|
|
|
var rows []row
|
|
if err := tx.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var res []Series
|
|
for _, r := range rows {
|
|
if n := len(res); n == 0 || res[n-1].Title != r.Title || res[n-1].Group != r.Group {
|
|
res = append(res, Series{Title: r.Title, Group: r.Group})
|
|
}
|
|
|
|
s := &res[len(res)-1]
|
|
s.Data = append(s.Data, Slot{
|
|
Start: time.Time(r.Start),
|
|
End: addDuration(time.Time(r.Start)),
|
|
Energy: roundEnergy(r.Energy),
|
|
ReturnEnergy: roundEnergy(r.ReturnEnergy),
|
|
})
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// hasReturnEnergy reports whether a group's CSV export includes a returnEnergy
|
|
// column. Only the bidirectional groups (grid, battery) do; the rest emit a
|
|
// single energy column.
|
|
func hasReturnEnergy(group string) bool {
|
|
return group == Grid || group == Battery
|
|
}
|
|
|
|
// WriteCsv emits a wide-table CSV with columns
|
|
//
|
|
// time.start, time.end, <group>.<entity>.energy.Wh[, <group>.<entity>.returnEnergy.Wh], …
|
|
//
|
|
// Only grid and battery contribute a second returnEnergy column.
|
|
// Values are plain Wh integers (the DB is already rounded to milli-kWh) — no
|
|
// decimal point, no thousands separator, so they survive locale-mismatched
|
|
// spreadsheet importers unambiguously.
|
|
func (s SeriesCSV) WriteCsv(ctx context.Context, w io.Writer) error {
|
|
ww, _, err := csvutil.NewLocalizedWriter(ctx, w)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
byGroup := make(map[string][]*Series)
|
|
for i := range s {
|
|
g := s[i].Group
|
|
byGroup[g] = append(byGroup[g], &s[i])
|
|
}
|
|
|
|
rank := make(map[string]int, len(GroupOrder))
|
|
for i, g := range GroupOrder {
|
|
rank[g] = i
|
|
}
|
|
groups := make([]string, 0, len(byGroup))
|
|
for g := range byGroup {
|
|
groups = append(groups, g)
|
|
}
|
|
sort.Slice(groups, func(i, j int) bool {
|
|
ri, oki := rank[groups[i]]
|
|
rj, okj := rank[groups[j]]
|
|
switch {
|
|
case oki && okj:
|
|
return ri < rj
|
|
case oki:
|
|
return true
|
|
case okj:
|
|
return false
|
|
default:
|
|
return groups[i] < groups[j]
|
|
}
|
|
})
|
|
|
|
header := []string{"time.start", "time.end"}
|
|
type col struct {
|
|
series *Series
|
|
returnEnergy bool
|
|
}
|
|
cols := []col{{}, {}}
|
|
tsSet := make(map[int64]time.Time)
|
|
endByStart := make(map[int64]time.Time)
|
|
|
|
label := func(e *Series, g string) string {
|
|
if e.Title != "" {
|
|
return e.Title
|
|
}
|
|
return g
|
|
}
|
|
|
|
prefix := func(g, l string) string {
|
|
if l == g {
|
|
return g
|
|
}
|
|
return g + "." + l
|
|
}
|
|
|
|
for _, g := range groups {
|
|
entities := byGroup[g]
|
|
sort.Slice(entities, func(i, j int) bool { return label(entities[i], g) < label(entities[j], g) })
|
|
|
|
for _, e := range entities {
|
|
p := prefix(g, label(e, g))
|
|
header = append(header, p+".energy.Wh")
|
|
cols = append(cols, col{series: e, returnEnergy: false})
|
|
if hasReturnEnergy(g) {
|
|
header = append(header, p+".returnEnergy.Wh")
|
|
cols = append(cols, col{series: e, returnEnergy: true})
|
|
}
|
|
for _, slot := range e.Data {
|
|
tsSet[slot.Start.UnixNano()] = slot.Start
|
|
endByStart[slot.Start.UnixNano()] = slot.End
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := ww.Write(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(tsSet) == 0 {
|
|
ww.Flush()
|
|
return ww.Error()
|
|
}
|
|
|
|
timestamps := make([]time.Time, 0, len(tsSet))
|
|
for _, t := range tsSet {
|
|
timestamps = append(timestamps, t)
|
|
}
|
|
sort.Slice(timestamps, func(i, j int) bool { return timestamps[i].Before(timestamps[j]) })
|
|
|
|
slotIdx := make(map[*Series]map[int64]Slot)
|
|
for _, list := range byGroup {
|
|
for _, e := range list {
|
|
m := make(map[int64]Slot, len(e.Data))
|
|
for _, slot := range e.Data {
|
|
m[slot.Start.UnixNano()] = slot
|
|
}
|
|
slotIdx[e] = m
|
|
}
|
|
}
|
|
|
|
row := make([]string, len(cols))
|
|
for _, ts := range timestamps {
|
|
row[0] = ts.Local().Format("2006-01-02 15:04:05")
|
|
row[1] = endByStart[ts.UnixNano()].Local().Format("2006-01-02 15:04:05")
|
|
for i := 2; i < len(cols); i++ {
|
|
c := cols[i]
|
|
if c.series == nil {
|
|
row[i] = ""
|
|
continue
|
|
}
|
|
slot, ok := slotIdx[c.series][ts.UnixNano()]
|
|
if !ok {
|
|
row[i] = ""
|
|
continue
|
|
}
|
|
v := slot.Energy
|
|
if c.returnEnergy {
|
|
v = slot.ReturnEnergy
|
|
}
|
|
row[i] = strconv.FormatInt(int64(math.Round(v*1000)), 10)
|
|
}
|
|
if err := ww.Write(row); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
ww.Flush()
|
|
return ww.Error()
|
|
}
|