chore: refactor timeseries (#19554)
This commit is contained in:
parent
ac0963263c
commit
44ca88412f
4 changed files with 219 additions and 114 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
95
core/timeseries.go
Normal 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
120
core/timeseries_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue