chore: refactor timeseries (#19554)

This commit is contained in:
andig 2025-03-08 11:45:21 +01:00 • committed by GitHub
parent ac0963263c
commit 44ca88412f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 219 additions and 114 deletions

View file

@ -1,7 +1,6 @@
package core
import (
"encoding/json"
"maps"
"math"
"slices"
@ -57,68 +56,6 @@ func (site *Site) effectiveCo2(greenShare float64) *float64 {
return nil
}
// accumulatedEnergy calculates the energy consumption between from and to,
// assuming the rates containing the power at given timestamp.
// Result is in Wh
func accumulatedEnergy(rr timeseries, from, to time.Time) float64 {
var energy float64
var last tsValue
for _, r := range rr {
// fmt.Println(r.Start.Local().Format(time.RFC3339), r.End.Local().Format(time.RFC3339), r.Price)
if !r.Timestamp.After(from) {
last = r
continue
}
x1 := last.Timestamp
y1 := last.Value
if x1.Before(from) {
x1 = from
y1 += float64(from.Sub(last.Timestamp)) * (r.Value - last.Value) / float64(r.Timestamp.Sub(last.Timestamp))
}
x2 := r.Timestamp
y2 := r.Value
if x2.After(to) {
x2 = to
y2 += float64(to.Sub(r.Timestamp)) * (r.Value - last.Value) / float64(r.Timestamp.Sub(last.Timestamp))
}
energy += (y1 + y2) / 2 * x2.Sub(x1).Hours()
if !r.Timestamp.Before(to) {
break
}
last = r
}
return energy
}
type (
timeseries []tsValue
tsValue struct {
Timestamp time.Time `json:"ts"`
Value float64 `json:"val"`
}
)
func (rr *timeseries) MarshalJSON() ([]byte, error) {
return json.Marshal(rr)
}
func timestampSeries(rr api.Rates) timeseries {
return lo.Map(rr, func(r api.Rate, _ int) tsValue {
return tsValue{
Timestamp: r.Start,
Value: r.Price,
}
})
}
func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) {
site.publish(keys.GreenShareHome, greenShareHome)
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
@ -183,9 +120,9 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
eod := bod.AddDate(0, 0, 1)
eot := eod.AddDate(0, 0, 1)
remainingToday := accumulatedEnergy(solar, time.Now(), eod)
tomorrow := accumulatedEnergy(solar, eod, eot)
dayAfterTomorrow := accumulatedEnergy(solar, eot, eot.AddDate(0, 0, 1))
remainingToday := solar.energy(time.Now(), eod)
tomorrow := solar.energy(eod, eot)
dayAfterTomorrow := solar.energy(eot, eot.AddDate(0, 0, 1))
fc.Solar.Today = dailyDetails{
Yield: remainingToday,
@ -201,7 +138,7 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
}
// accumulate forecasted energy since last update
site.fcstEnergy.AddEnergy(accumulatedEnergy(solar, site.fcstEnergy.updated, time.Now()) / 1e3)
site.fcstEnergy.AddEnergy(solar.energy(site.fcstEnergy.updated, time.Now()) / 1e3)
settings.SetFloat(keys.SolarAccForecast, site.fcstEnergy.Accumulated)
produced := lo.SumBy(slices.Collect(maps.Values(site.pvEnergy)), func(v *meterEnergy) float64 {

View file

@ -1,47 +0,0 @@
package core
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/jinzhu/now"
"github.com/stretchr/testify/assert"
)
func TestAccumulatedEnergy(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
rate := func(start int, val float64) tsValue {
return tsValue{
Timestamp: clock.Now().Add(time.Duration(start) * time.Hour),
Value: val,
}
}
rr := timeseries{rate(0, 0), rate(1, 1), rate(2, 2), rate(3, 3), rate(4, 4)}
for i, tc := range []struct {
from, to float64
expected float64
}{
{0, 0, 0},
{0, 0.5, 0.125},
{0, 1, 0.5},
{0, 1.5, 1.125},
{0, 2, 2},
{1, 2, 1.5},
{0.25, 0.75, 0.25},
{0.5, 1, 0.375},
{0.5, 3.5, 6},
} {
t.Logf("%d. %+v", i+1, tc)
from := clock.Now().Add(time.Duration(float64(time.Hour) * tc.from))
to := clock.Now().Add(time.Duration(float64(time.Hour) * tc.to))
res := accumulatedEnergy(rr, from, to)
assert.Equal(t, tc.expected, res, "test case %d", i+1)
}
}

95
core/timeseries.go Normal file
View file

@ -0,0 +1,95 @@
package core
import (
"slices"
"time"
"github.com/evcc-io/evcc/api"
"github.com/samber/lo"
)
// timeseries is a sorted list of timestamped values
// methods are optimized for fast searching and interpolation
type timeseries []tsval
type tsval struct {
Timestamp time.Time `json:"ts"`
Value float64 `json:"val"`
}
func (rr timeseries) search(ts time.Time) (int, bool) {
return slices.BinarySearchFunc(rr, ts, func(v tsval, ts time.Time) int {
return v.Timestamp.Compare(ts)
})
}
// interpolate returns the interpolated value where ts is between two entries and i is the index of the rate after ts
func (rr timeseries) interpolate(i int, ts time.Time) float64 {
rp := &rr[i-1]
r := &rr[i]
return rp.Value + float64(ts.Sub(rp.Timestamp))*(r.Value-rp.Value)/float64(r.Timestamp.Sub(rp.Timestamp))
}
func (rr timeseries) value(ts time.Time) float64 {
idx, ok := rr.search(ts)
if ok {
return rr[idx].Value
}
if idx == 0 || idx >= len(rr) {
return 0
}
return rr.interpolate(idx, ts)
}
// energy calculates the energy consumption between from and to,
// assuming the rates containing the power at given timestamp.
// Result is in Wh
func (rr timeseries) energy(from, to time.Time) float64 {
var energy float64
idx, ok := rr.search(from)
if !ok {
switch {
case idx >= len(rr):
// from is just before or after last entry
return 0
case idx == 0:
// from is before first entry
// do nothing- we ignore anything before the first entry
default:
// from is between two entries
r := &rr[idx]
vp := rr.interpolate(idx, from)
// to is before same entry as from
if r.Timestamp.After(to) {
return (vp + rr.interpolate(idx, to)) / 2 * to.Sub(from).Hours()
}
energy += (vp + r.Value) / 2 * r.Timestamp.Sub(from).Hours()
}
}
for ; idx < len(rr)-1; idx++ {
r := &rr[idx]
rn := &rr[idx+1]
if rn.Timestamp.After(to) {
energy += (r.Value + rr.interpolate(idx+1, to)) / 2 * to.Sub(r.Timestamp).Hours()
break
}
energy += (r.Value + rn.Value) / 2 * rn.Timestamp.Sub(r.Timestamp).Hours()
}
return energy
}
func timestampSeries(rr api.Rates) timeseries {
return lo.Map(rr, func(r api.Rate, _ int) tsval {
return tsval{
Timestamp: r.Start,
Value: r.Price,
}
})
}

120
core/timeseries_test.go Normal file
View file

@ -0,0 +1,120 @@
package core
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/jinzhu/now"
"github.com/stretchr/testify/suite"
)
func TestTimeseries(t *testing.T) {
suite.Run(t, new(timeseriesTestSuite))
}
type timeseriesTestSuite struct {
suite.Suite
clock *clock.Mock
rr timeseries
}
func (t *timeseriesTestSuite) rate(start int, val float64) tsval {
return tsval{
Timestamp: t.clock.Now().Add(time.Duration(start) * time.Hour),
Value: val,
}
}
func (t *timeseriesTestSuite) SetupSuite() {
t.clock = clock.NewMock()
t.clock.Set(now.BeginningOfDay())
t.rr = timeseries{t.rate(0, 0), t.rate(1, 1), t.rate(2, 2), t.rate(3, 3), t.rate(4, 4)}
}
func (t *timeseriesTestSuite) TestIndex() {
for i, tc := range []struct {
ts float64
idx int
ok bool
}{
{-1, 0, false},
{0, 0, true},
{0.5, 1, false},
{1, 1, true},
{99, len(t.rr), false},
} {
ts := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.ts))
res, ok := t.rr.search(ts)
t.Equal(tc.idx, res, "%d. idx %+v", i+1, tc)
t.Equal(tc.ok, ok, "%d. ok %+v", i+1, tc)
}
}
func (t *timeseriesTestSuite) TestValue() {
for i, tc := range []struct {
ts, val float64
}{
{-1, 0},
{0, 0},
{0.5, 0.5},
{1, 1},
{4, 4},
{99, 0},
} {
ts := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.ts))
res := t.rr.value(ts)
t.Equal(tc.val, res, "%d. %+v", i+1, tc)
}
}
func (t *timeseriesTestSuite) TestEnergy() {
for i, tc := range []struct {
from, to float64
expected float64
}{
{-1, 0, 0},
{-1, 1, 0.5},
{-1, 90, 8},
{0, 0, 0},
{0, 0.5, 0.125},
{0, 1, 0.5},
{0, 1.5, 1.125},
{0, 2, 2},
{1, 2, 1.5},
{0.25, 0.75, 0.25},
{0.5, 1, 0.375},
{0.5, 3.5, 6},
{80, 90, 0},
} {
from := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.from))
to := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.to))
res := t.rr.energy(from, to)
t.Equal(tc.expected, res, "%d. %+v", i+1, tc)
}
}
func (t *timeseriesTestSuite) TestShort() {
t.clock.Set(now.BeginningOfDay())
rr := timeseries{t.rate(0, 0), t.rate(1, 1)}
for i, tc := range []struct {
from, to, energy, value float64
}{
{-1, 0, 0, 0},
// {-1, 0.5, 0.125, 0.5},
// {-1, 2, 0.5, 0},
{0, 0, 0, 0},
{0, 0.5, 0.125, 0.5},
{0, 1, 0.5, 1},
{0, 1.5, 0.5, 0},
{1.5, 2, 0, 0},
} {
from := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.from))
to := t.clock.Now().Add(time.Duration(float64(time.Hour) * tc.to))
t.Equal(tc.energy, rr.energy(from, to), "%d. energy %+v", i+1, tc)
t.Equal(tc.value, rr.value(to), "%d. value %+v", i+1, tc)
}
}