Site: only re-evaluate HEMS dim and curtail state on change (#32011)

This commit is contained in:
andig 2026-07-21 13:38:20 +02:00 • committed by GitHub
parent 608aa36629
commit 13aab7c622
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 171 additions and 1 deletions

View file

@ -75,6 +75,10 @@ type Site struct {
auxMeters []config.Device[api.Meter] // Auxiliary meters
consumerMeters []config.Device[api.Meter] // Consumer meters
// last applied HEMS state, nil until applied or after a failed attempt
dimmed *bool
curtailPercent *int
// battery settings
prioritySoc float64 // prefer battery up to this Soc
bufferSoc float64 // continue charging on battery above this Soc

View file

@ -56,7 +56,16 @@ func (site *Site) publishCircuits() {
site.publish(keys.Circuits, res)
}
// dimMeters applies the HEMS dim state to all dimmable aux and ext meters.
// Devices are only queried when the state changes or after a failed attempt.
func (site *Site) dimMeters(dim bool) error {
if site.dimmed != nil && *site.dimmed == dim {
return nil
}
// invalidate until successfully applied
site.dimmed = nil
var errs error
for _, dev := range slices.Concat(site.auxMeters, site.extMeters) {
m, ok := api.Cap[api.Dimmer](dev.Instance())
@ -82,14 +91,23 @@ func (site *Site) dimMeters(dim bool) error {
}
}
if errs == nil {
site.dimmed = &dim
}
return errs
}
// curtailPV applies the HEMS curtailment percent to all curtailable pv meters.
// Devices are only queried when the percent changes or after a failed attempt.
func (site *Site) curtailPV(percent *int) error {
if percent == nil {
if percent == nil || site.curtailPercent != nil && *site.curtailPercent == *percent {
return nil
}
// invalidate until successfully applied
site.curtailPercent = nil
var errs error
for _, dev := range site.pvMeters {
m, ok := api.Cap[api.Curtailer](dev.Instance())
@ -115,5 +133,9 @@ func (site *Site) curtailPV(percent *int) error {
}
}
if errs == nil {
site.curtailPercent = new(*percent)
}
return errs
}

144
core/site_circuits_test.go Normal file
View file

@ -0,0 +1,144 @@
package core
import (
"errors"
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// curtailableMeter counts device interactions to verify caching.
type curtailableMeter struct {
api.Meter
percent int
setErr error
gets int
sets int
setCalls []int
}
func (m *curtailableMeter) CurtailedPercent() (int, error) {
m.gets++
return m.percent, nil
}
func (m *curtailableMeter) SetCurtailPercent(percent int) error {
m.sets++
m.setCalls = append(m.setCalls, percent)
if m.setErr != nil {
return m.setErr
}
m.percent = percent
return nil
}
func curtailSite(m api.Meter) *Site {
return &Site{
log: util.NewLogger("foo"),
pvMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, m)},
}
}
// The HEMS percent is applied once and not re-evaluated while it stays unchanged.
func TestCurtailPVCache(t *testing.T) {
m := &curtailableMeter{percent: 100}
site := curtailSite(m)
require.NoError(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60}, m.setCalls)
assert.Equal(t, 1, m.gets)
// unchanged: device is not queried again
for range 3 {
require.NoError(t, site.curtailPV(new(60)))
}
assert.Equal(t, []int{60}, m.setCalls)
assert.Equal(t, 1, m.gets)
// changed: applied again. A bool device state could not distinguish 60 from 30
require.NoError(t, site.curtailPV(new(30)))
assert.Equal(t, []int{60, 30}, m.setCalls)
require.NoError(t, site.curtailPV(new(100)))
assert.Equal(t, []int{60, 30, 100}, m.setCalls)
}
// A failed write must be retried on the next cycle instead of being cached.
func TestCurtailPVCacheRetriesAfterError(t *testing.T) {
m := &curtailableMeter{percent: 100, setErr: errors.New("nope")}
site := curtailSite(m)
require.Error(t, site.curtailPV(new(60)))
require.Error(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60, 60}, m.setCalls)
m.setErr = nil
require.NoError(t, site.curtailPV(new(60)))
require.NoError(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60, 60, 60}, m.setCalls)
}
// nil percent means the HEMS makes no statement and must not touch the cache.
func TestCurtailPVNoStatement(t *testing.T) {
m := &curtailableMeter{percent: 100}
site := curtailSite(m)
require.NoError(t, site.curtailPV(new(60)))
require.NoError(t, site.curtailPV(nil))
require.NoError(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60}, m.setCalls)
}
// dimmableMeter counts device interactions to verify caching.
type dimmableMeter struct {
api.Meter
dimmed bool
dimErr error
gets int
dimCalls []bool
}
func (m *dimmableMeter) Dimmed() (bool, error) {
m.gets++
return m.dimmed, nil
}
func (m *dimmableMeter) Dim(dim bool) error {
m.dimCalls = append(m.dimCalls, dim)
if m.dimErr != nil {
return m.dimErr
}
m.dimmed = dim
return nil
}
func TestDimMetersCache(t *testing.T) {
m := &dimmableMeter{}
site := &Site{
log: util.NewLogger("foo"),
auxMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, api.Meter(m))},
}
require.NoError(t, site.dimMeters(true))
assert.Equal(t, []bool{true}, m.dimCalls)
assert.Equal(t, 1, m.gets)
for range 3 {
require.NoError(t, site.dimMeters(true))
}
assert.Equal(t, []bool{true}, m.dimCalls)
assert.Equal(t, 1, m.gets)
require.NoError(t, site.dimMeters(false))
assert.Equal(t, []bool{true, false}, m.dimCalls)
// failed write is retried
m.dimErr = errors.New("nope")
require.Error(t, site.dimMeters(true))
require.Error(t, site.dimMeters(true))
assert.Equal(t, []bool{true, false, true, true}, m.dimCalls)
}