Forecast: match persisted solar history to the published curve (#32693)
This commit is contained in:
parent
4465100a65
commit
a938d45eed
4 changed files with 85 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue