Repository: evcc-io/evcc
Pull request #19069 (2025-02-22 15:18:26)

Solar forecast: keep today's rates when merging
@naltatis fyi


Patch:
diff --git a/tariff/helper.go b/tariff/helper.go
index 9cbb8cc4..85d4f54e 100644
--- a/tariff/helper.go
+++ b/tariff/helper.go
@@ -10,6 +10,7 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
 	"github.com/evcc-io/evcc/util/request"
+	"github.com/jinzhu/now"
 )
 
 // Name returns the tariff type name
@@ -40,9 +41,13 @@ func backoffPermanentError(err error) error {
 	return err
 }
 
-// mergeRates merges new rates into existing rates,
-// keeping current slots from the existing rates.
+// mergeRates blends new and existing rates, keeping existing rates after current hour
 func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
+	mergeRatesAfter(data, new, now.With(time.Now()).BeginningOfHour())
+}
+
+// mergeRatesAfter blends new and existing rates, keeping existing rates after timestamp
+func mergeRatesAfter(data *util.Monitor[api.Rates], new api.Rates, now time.Time) {
 	new.Sort()
 
 	var newStart time.Time
@@ -51,11 +56,9 @@ func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
 	}
 
 	data.SetFunc(func(old api.Rates) api.Rates {
-		now := time.Now()
-
 		var between api.Rates
 		for _, r := range old {
-			if (r.Start.Before(newStart) || newStart.IsZero()) && r.End.After(now) {
+			if (r.Start.Before(newStart) && !r.Start.Before(now) || newStart.IsZero()) && r.End.After(now) {
 				between = append(between, r)
 			}
 		}
@@ -63,3 +66,8 @@ func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
 		return append(between, new...)
 	})
 }
+
+// BeginningOfDay returns the beginning of the current day
+func BeginningOfDay() time.Time {
+	return now.With(time.Now()).BeginningOfDay()
+}
diff --git a/tariff/solcast.go b/tariff/solcast.go
index ec6885e1..9dcf2004 100644
--- a/tariff/solcast.go
+++ b/tariff/solcast.go
@@ -108,7 +108,7 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
 			data = append(data, rr)
 		}
 
-		mergeRates(t.data, data)
+		mergeRatesAfter(t.data, data, BeginningOfDay())
 		once.Do(func() { close(done) })
 	}
 }
diff --git a/tariff/tariff.go b/tariff/tariff.go
index 7a119a91..3b297df2 100644
--- a/tariff/tariff.go
+++ b/tariff/tariff.go
@@ -112,7 +112,13 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
 			continue
 		}
 
-		mergeRates(t.data, data)
+		// only prune rates older than current period
+		periodStart := now.With(time.Now()).BeginningOfHour()
+		if t.typ == api.TariffTypeSolar {
+			periodStart = BeginningOfDay()
+		}
+		mergeRatesAfter(t.data, data, periodStart)
+
 		once.Do(func() { close(done) })
 	}
 }
diff --git a/util/monitor.go b/util/monitor.go
index 499dbc62..ef1f16fa 100644
--- a/util/monitor.go
+++ b/util/monitor.go
@@ -4,12 +4,14 @@ import (
 	"sync"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 )
 
 // Monitor monitors values for regular updates
 type Monitor[T any] struct {
 	val     T
+	clock   clock.Clock
 	mu      sync.RWMutex
 	once    sync.Once
 	done    chan struct{}
@@ -19,10 +21,18 @@ type Monitor[T any] struct {
 
 // NewMonitor created a new monitor with given timeout
 func NewMonitor[T any](timeout time.Duration) *Monitor[T] {
-	return &Monitor[T]{
+	res := &Monitor[T]{
+		clock:   clock.New(),
 		done:    make(chan struct{}),
 		timeout: timeout,
 	}
+
+	return res
+}
+
+// WithClock sets the a clock for debugging
+func (m *Monitor[T]) WithClock(clock clock.Clock) {
+	m.clock = clock
 }
 
 // Set updates the current value and timestamp
@@ -36,7 +46,7 @@ func (m *Monitor[T]) SetFunc(set func(T) T) {
 	defer m.mu.Unlock()
 
 	m.val = set(m.val)
-	m.updated = time.Now()
+	m.updated = m.clock.Now()
 
 	m.once.Do(func() { close(m.done) })
 }
@@ -66,7 +76,7 @@ func (m *Monitor[T]) GetFunc(get func(T)) error {
 		}
 	}
 
-	if time.Since(m.updated) > m.timeout {
+	if m.clock.Since(m.updated) > m.timeout {
 		err := api.ErrOutdated
 
 		// wait once on very first call
@@ -83,7 +93,7 @@ func (m *Monitor[T]) GetFunc(get func(T)) error {
 			case <-m.done:
 				// got value and updated timestamp
 				err = nil
-			case <-time.After(m.timeout):
+			case <-m.clock.After(m.timeout):
 			}
 
 			m.mu.RLock()


Test patch:
diff --git a/tariff/helper_test.go b/tariff/helper_test.go
new file mode 100644
index 00000000..8605a643
--- /dev/null
+++ b/tariff/helper_test.go
@@ -0,0 +1,55 @@
+package tariff
+
+import (
+	"testing"
+	"time"
+
+	"github.com/benbjohnson/clock"
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/jinzhu/now"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestMergeRatesAfter(t *testing.T) {
+	clock := clock.NewMock()
+	rate := func(start int, val float64) api.Rate {
+		return api.Rate{
+			Start: clock.Now().Add(time.Duration(start) * time.Hour),
+			End:   clock.Now().Add(time.Duration(start+1) * time.Hour),
+			Price: val,
+		}
+	}
+
+	old := api.Rates{rate(1, 1), rate(2, 2)}
+	new := api.Rates{rate(2, 2), rate(3, 3)}
+	combined := api.Rates{rate(1, 1), rate(2, 2), rate(3, 3)}
+
+	data := util.NewMonitor[api.Rates](time.Hour)
+	data.WithClock(clock)
+
+	data.Set(old)
+	res, err := data.Get()
+	require.NoError(t, err)
+	assert.Equal(t, old, res)
+
+	for i, tc := range []struct {
+		new, expected api.Rates
+		ts            time.Time
+	}{
+		{new, combined, clock.Now()},
+		{nil, combined, clock.Now()},
+		{new, combined, clock.Now().Add(time.Hour)},
+		{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour()},
+		{new, new, clock.Now().Add(2 * time.Hour)},
+	} {
+		t.Logf("%d. %+v", i+1, tc)
+
+		mergeRatesAfter(data, tc.new, tc.ts)
+
+		res, err := data.Get()
+		require.NoError(t, err)
+		assert.Equal(t, tc.expected, res)
+	}
+}