From a938d45eed61e47aed6ab162e5546f3418779acc Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 11 Aug 2026 16:02:14 +0200 Subject: [PATCH] Forecast: match persisted solar history to the published curve (#32693) --- core/metrics/collector.go | 19 +++++++++++++++++++ core/metrics/collector_test.go | 31 +++++++++++++++++++++++++++++++ core/site_tariffs.go | 14 ++++++++++---- core/site_tariffs_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/core/metrics/collector.go b/core/metrics/collector.go index ac55601bc..50deccd29 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -103,6 +103,12 @@ func (c *Collector) process(fun func()) error { fun() + return c.advanceSlot(now) +} + +// advanceSlot persists and resets the accumulator when now has entered a new +// slot, leaving it untouched within the current one. +func (c *Collector) advanceSlot(now time.Time) error { slotStart := now.Truncate(tariff.SlotDuration) switch { @@ -183,6 +189,19 @@ func (c *Collector) LastSlotEnergy() (float64, bool) { return m.Energy, true } +// SetEnergy overwrites the current slot's energy in kWh for sources that yield +// the slot total rather than a delta. Repeated calls within a slot are +// idempotent, so the caller needs no tick bookkeeping of its own. +func (c *Collector) SetEnergy(energy float64) error { + // advance first- the completed slot keeps the value it was last set to + if err := c.advanceSlot(c.accu.clock.Now()); err != nil { + return err + } + + c.accu.Energy = energy + return nil +} + func (c *Collector) SetEnergyMeterTotal(v float64) error { return c.process(func() { c.accu.SetEnergyMeterTotal(v) diff --git a/core/metrics/collector_test.go b/core/metrics/collector_test.go index 0f4565e69..84b0e1149 100644 --- a/core/metrics/collector_test.go +++ b/core/metrics/collector_test.go @@ -569,3 +569,34 @@ func TestCollectorLastSlotEnergy(t *testing.T) { _, ok = col.LastSlotEnergy() require.False(t, ok) } + +func TestCollectorSetEnergy(t *testing.T) { + clk := clock.NewMock() + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector(Forecast, "set", "", WithClock(clk)) + require.NoError(t, err) + + // repeated sets within a slot are idempotent + require.NoError(t, col.SetEnergy(1)) + require.Equal(t, 1.0, col.accu.Energy) + clk.Add(5 * time.Minute) + require.NoError(t, col.SetEnergy(1)) + require.Equal(t, 1.0, col.accu.Energy) + + // a revised value replaces it rather than accumulating + clk.Add(5 * time.Minute) // 00:10 + require.NoError(t, col.SetEnergy(1.5)) + require.Equal(t, 1.5, col.accu.Energy) + + // crossing the boundary persists the value last set in the completed slot + clk.Add(5 * time.Minute) // 00:15 + require.NoError(t, col.SetEnergy(2)) + require.Equal(t, 2.0, col.accu.Energy) + + v, ok := col.LastSlotEnergy() + require.True(t, ok) + require.Equal(t, 1.5, v) +} diff --git a/core/site_tariffs.go b/core/site_tariffs.go index e39424827..b62c99bc7 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -188,6 +188,14 @@ func (site *Site) persistTariffs() { } } +// forecastSlotEnergy is the energy expected in the slot covering now, integrated +// the same way as the published forecast so the persisted history matches the +// curve the UI draws. Beyond the forecast horizon it is zero. +func forecastSlotEnergy(solar api.Rates, now time.Time) float64 { + slot := now.Truncate(tariff.SlotDuration) + return solarEnergy(solar, slot, slot.Add(tariff.SlotDuration)) / 1e3 +} + func (site *Site) solarDetails(solar api.Rates) solarDetails { res := solarDetails{ Timeseries: solarTimeseries(solar), @@ -216,10 +224,8 @@ func (site *Site) solarDetails(solar api.Rates) solarDetails { Complete: !last.Before(eot.AddDate(0, 0, 1)), } - if r, err := solar.At(time.Now()); err == nil { - if err := site.collectors[metrics.Forecast].AddEnergy(nil, nil, r.Value); err != nil { - site.log.ERROR.Printf("solar forecast collector: %v", err) - } + if err := site.collectors[metrics.Forecast].SetEnergy(forecastSlotEnergy(solar, time.Now())); err != nil { + site.log.ERROR.Printf("solar forecast collector: %v", err) } if r, err := tariff.At(site.GetTariff(api.TariffUsageTemperature), time.Now()); err == nil { diff --git a/core/site_tariffs_test.go b/core/site_tariffs_test.go index 023f55e5e..c8dd322ce 100644 --- a/core/site_tariffs_test.go +++ b/core/site_tariffs_test.go @@ -6,10 +6,35 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestForecastSlotEnergy(t *testing.T) { + slot := time.Unix(1735689600, 0).Truncate(tariff.SlotDuration) + rate := func(i int, power float64) api.Rate { + return api.Rate{ + Start: slot.Add(time.Duration(i) * tariff.SlotDuration), + End: slot.Add(time.Duration(i+1) * tariff.SlotDuration), + Value: power, + } + } + + rr := api.Rates{rate(0, 4000), rate(1, 8000)} + + // trapezoidal like the published curve, (4kW + 8kW) / 2 * 15min, + // and identical anywhere inside the slot + assert.Equal(t, 1.5, forecastSlotEnergy(rr, slot)) + assert.Equal(t, 1.5, forecastSlotEnergy(rr, slot.Add(time.Minute))) + + // the last sample has no successor to integrate towards, as for the daily totals + assert.Equal(t, 0.0, forecastSlotEnergy(rr, slot.Add(tariff.SlotDuration))) + + // beyond the forecast horizon + assert.Equal(t, 0.0, forecastSlotEnergy(rr, slot.Add(2*tariff.SlotDuration))) +} + func TestForecastRates(t *testing.T) { start := time.Unix(1735689600, 0)