Forecast: match persisted solar history to the published curve (#32693)

This commit is contained in:
andig 2026-08-11 16:02:14 +02:00 • committed by GitHub
parent 4465100a65
commit a938d45eed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 85 additions and 4 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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 {

View file

@ -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)