From 44ca88412f97d051407f4faeb6f395820f77fbd3 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 8 Mar 2025 11:45:21 +0100 Subject: [PATCH] chore: refactor timeseries (#19554) --- core/site_tariffs.go | 71 ++-------------------- core/site_tariffs_test.go | 47 --------------- core/timeseries.go | 95 ++++++++++++++++++++++++++++++ core/timeseries_test.go | 120 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 114 deletions(-) delete mode 100644 core/site_tariffs_test.go create mode 100644 core/timeseries.go create mode 100644 core/timeseries_test.go diff --git a/core/site_tariffs.go b/core/site_tariffs.go index 537b5924e..49d376812 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -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 { diff --git a/core/site_tariffs_test.go b/core/site_tariffs_test.go deleted file mode 100644 index 6a4a78c6a..000000000 --- a/core/site_tariffs_test.go +++ /dev/null @@ -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) - } -} diff --git a/core/timeseries.go b/core/timeseries.go new file mode 100644 index 000000000..3bfcd2800 --- /dev/null +++ b/core/timeseries.go @@ -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, + } + }) +} diff --git a/core/timeseries_test.go b/core/timeseries_test.go new file mode 100644 index 000000000..d09462318 --- /dev/null +++ b/core/timeseries_test.go @@ -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) + } +}