Site: separate measurement from power calculation in update cycle (#32770)

This commit is contained in:
andig 2026-08-20 20:19:56 +02:00 • committed by GitHub
parent e7b694e550
commit a578893edd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 276 additions and 172 deletions

View file

@ -106,12 +106,9 @@ type Site struct {
collectors map[string]*metrics.Collector // keyed by meter ref
tariffSlot time.Time // last persisted tariff slot
// cached state
gridPower float64 // Grid power
pvPower float64 // PV power
excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power
battery types.BatteryState // Battery cached and published state
// cached measurement state, guarded by RWMutex
siteState
batteryMaxDischargePower *float64 // Max discharge power of all battery meters
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
@ -126,6 +123,22 @@ type Site struct {
solarScaleCached func() (float64, error) // util.Cached wrapper around querySolarScale
}
// siteState is the site's cached measurement state, updated once per meter cycle
type siteState struct {
gridPower float64 // Grid power
pvPower float64 // PV power
excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power
battery types.BatteryState // Battery cached and published state
}
// state returns a copy of the cached measurement state
func (site *Site) state() siteState {
site.RLock()
defer site.RUnlock()
return site.siteState
}
// MetersConfig contains the site's meter configuration
type MetersConfig struct {
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
@ -694,10 +707,10 @@ func (site *Site) updatePvMeters() {
}
}
site.pvPower = lo.SumBy(mm, func(m types.Measurement) float64 {
pvPower := lo.SumBy(mm, func(m types.Measurement) float64 {
return max(0, m.Power)
})
site.excessDCPower = lo.SumBy(mm, func(m types.Measurement) float64 {
excessDCPower := lo.SumBy(mm, func(m types.Measurement) float64 {
return math.Abs(m.ExcessDCPower)
})
totalEnergy := lo.SumBy(mm, func(m types.Measurement) float64 {
@ -707,16 +720,20 @@ func (site *Site) updatePvMeters() {
return *m.Energy
})
site.Lock()
site.pvPower, site.excessDCPower = pvPower, excessDCPower
site.Unlock()
if len(site.pvMeters) > 1 {
var excessStr string
if site.excessDCPower > 0 {
excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", site.excessDCPower)
if excessDCPower > 0 {
excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", excessDCPower)
}
site.log.DEBUG.Printf("pv power: %.0fW"+excessStr, site.pvPower)
site.log.DEBUG.Printf("pv power: %.0fW"+excessStr, pvPower)
}
site.publish(keys.PvPower, site.pvPower)
site.publish(keys.PvPower, pvPower)
site.publish(keys.PvEnergy, totalEnergy)
site.publish(keys.Pv, mm)
@ -778,20 +795,20 @@ func (site *Site) updateBatteryMeters() {
mm[i].Controllable = new(controllable)
}
// written from the meter goroutine, read via GetBatteryMaxDischargePower
// retain the last known soc when all reads failed, so that a transient
// meter error does not report the pack as empty (0%)
socFailed := lo.EveryBy(mm, func(m types.Measurement) bool { return m.Soc == nil })
// written from the meter goroutine, read via state and GetBatteryMaxDischargePower
site.Lock()
if maxDischargePower >= 0 {
site.batteryMaxDischargePower = &maxDischargePower
} else {
site.batteryMaxDischargePower = nil
}
site.Unlock()
// retain the last known soc when every battery read failed this cycle, so a
// transient meter error does not report the pack as empty (0%)
if lo.EveryBy(mm, func(m types.Measurement) bool { return m.Soc == nil }) {
site.log.WARN.Printf("battery soc: read failed, keeping last %.0f%%", site.battery.Soc)
} else {
if !socFailed {
var batterySocAcc float64
var totalCapacity float64
@ -818,13 +835,19 @@ func (site *Site) updateBatteryMeters() {
}
return *m.Energy
})
site.battery.Devices = mm
if len(site.batteryMeters) > 1 {
site.log.DEBUG.Printf("battery power: %.0fW", site.battery.Power)
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc))
battery := site.battery
site.Unlock()
if socFailed {
site.log.WARN.Printf("battery soc: read failed, keeping last %.0f%%", battery.Soc)
}
site.battery.Devices = mm
if len(site.batteryMeters) > 1 {
site.log.DEBUG.Printf("battery power: %.0fW", battery.Power)
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(battery.Soc))
}
// accumulate per-battery energy (charging = import, discharging = export — from battery POV toward grid root)
for i, dev := range site.batteryMeters {
@ -847,11 +870,13 @@ func (site *Site) updateBatteryMeters() {
// publishBattery applies the optimizer suggestions and publishes the battery state
func (site *Site) publishBattery() {
mode := site.GetBatteryMode().String()
for i, d := range site.battery.Devices {
site.battery.Devices[i].Suggestion = site.suggestion(batteryKey(d.Name), mode)
battery := site.state().battery
for i, d := range battery.Devices {
battery.Devices[i].Suggestion = site.suggestion(batteryKey(d.Name), mode)
}
site.publish(keys.Battery, site.battery)
site.publish(keys.Battery, battery)
}
func sumOfSocs(mm []types.Measurement) float64 {
@ -894,17 +919,21 @@ func (site *Site) updateAuxMeters() {
}
mm := site.collectMeters("aux", site.auxMeters)
site.auxPower = lo.SumBy(mm, func(m types.Measurement) float64 {
auxPower := lo.SumBy(mm, func(m types.Measurement) float64 {
return m.Power
})
site.Lock()
site.auxPower = auxPower
site.Unlock()
if len(site.auxMeters) > 1 {
site.log.DEBUG.Printf("aux power: %.0fW", site.auxPower)
site.log.DEBUG.Printf("aux power: %.0fW", auxPower)
}
site.addMeterEnergy(site.auxMeters, mm)
site.publish(keys.AuxPower, site.auxPower)
site.publish(keys.AuxPower, auxPower)
site.publish(keys.Aux, mm)
}
@ -946,7 +975,11 @@ func (site *Site) updateGridMeter() error {
if res, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()); err == nil {
mm.Power = res
site.Lock()
site.gridPower = res
site.Unlock()
site.log.DEBUG.Printf("grid power: %.0fW", res)
} else if !errors.Is(err, api.ErrNotAvailable) {
return fmt.Errorf("grid power: %v", err)
@ -1003,7 +1036,8 @@ func (site *Site) updateGridMeter() error {
return nil
}
func (site *Site) updateMeters() error {
// updateMeters reads all meters and returns the updated measurement state
func (site *Site) updateMeters() (siteState, error) {
var eg errgroup.Group
eg.Go(func() error { site.updatePvMeters(); return nil })
@ -1015,12 +1049,10 @@ func (site *Site) updateMeters() error {
eg.Go(site.updateGridMeter)
if err := eg.Wait(); err != nil {
return err
return siteState{}, err
}
go site.optimizerUpdateAsync(tariff.SlotDuration)
return nil
return site.state(), nil
}
func optimizerEnabled() bool {
@ -1029,30 +1061,46 @@ func optimizerEnabled() bool {
return exp && opt
}
// sitePower returns
// - the net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging
// - if battery buffer can be used for charging
// - the adjustment applied to sitePower for battery priority below prioritySoc;
// adding it back restores the unadjusted site power for a loadpoint that
// takes priority over the battery (battery boost)
func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, bool, bool, float64, error) {
if err := site.updateMeters(); err != nil {
return 0, false, false, 0, err
// sitePowerResult is the outcome of the site power calculation
type sitePowerResult struct {
// measured state, including the estimates for missing meters
state siteState
// net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging)
power float64
// battery buffer can be used for charging
batteryBuffered bool
// charging may start off the battery
batteryStart bool
// adjustment applied for battery priority below prioritySoc; adding it back
// restores the unadjusted site power for a battery-boosting loadpoint
priorityAdjustment float64
}
// sitePower calculates the site power balance from the measured state
func (site *Site) sitePower(state siteState, totalChargePower, flexiblePower float64) sitePowerResult {
// ensure safe default for residual power
residualPower := site.GetResidualPower()
site.RLock()
prioritySoc, bufferSoc, bufferStartSoc := site.prioritySoc, site.bufferSoc, site.bufferStartSoc
site.RUnlock()
// allow using PV as estimate for grid power
if site.gridMeter == nil {
site.gridPower = totalChargePower - site.pvPower
site.publish(keys.Grid, types.Measurement{Power: site.gridPower})
state.gridPower = totalChargePower - state.pvPower
site.publish(keys.Grid, types.Measurement{Power: state.gridPower})
}
// sitePower adjustment applied for battery priority
var priorityAdjustment float64
// ensure safe default for residual power
residualPower := site.GetResidualPower()
if len(site.batteryMeters) > 0 && site.battery.Soc < site.prioritySoc && residualPower <= 0 {
hasBattery := len(site.batteryMeters) > 0
if hasBattery && state.battery.Soc < prioritySoc && residualPower <= 0 {
priorityAdjustment += residualPower - 100
residualPower = 100 // W
}
@ -1060,39 +1108,33 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
// allow using grid, charge and battery power as estimate for pv power
// needs a grid meter, otherwise grid power is itself derived from pv power (see above)
if site.pvMeters == nil && site.gridMeter != nil {
site.pvPower = totalChargePower - site.gridPower - site.battery.Power + residualPower
if site.pvPower < 0 {
site.pvPower = 0
}
site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
site.publish(keys.PvPower, site.pvPower)
state.pvPower = max(0, totalChargePower-state.gridPower-state.battery.Power+residualPower)
site.log.DEBUG.Printf("pv power: %.0fW", state.pvPower)
site.publish(keys.PvPower, state.pvPower)
}
// honour battery priority
batteryPower := site.battery.Power
excessDCPower := site.excessDCPower
batteryPower := state.battery.Power
excessDCPower := state.excessDCPower
// handed to loadpoint
var batteryBuffered, batteryStart bool
if len(site.batteryMeters) > 0 {
site.RLock()
defer site.RUnlock()
if hasBattery {
// if battery is charging below prioritySoc give it priority
if site.battery.Soc < site.prioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.battery.Soc, site.prioritySoc)
if state.battery.Soc < prioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", state.battery.Soc, prioritySoc)
priorityAdjustment += batteryPower + excessDCPower
batteryPower = 0
excessDCPower = 0
} else {
// if battery is above bufferSoc allow using it for charging
batteryBuffered = site.bufferSoc > 0 && site.battery.Soc > site.bufferSoc
batteryStart = site.bufferStartSoc > 0 && site.battery.Soc >= site.bufferStartSoc
batteryBuffered = bufferSoc > 0 && state.battery.Soc > bufferSoc
batteryStart = bufferStartSoc > 0 && state.battery.Soc >= bufferStartSoc
}
}
sitePower := site.gridPower + batteryPower + excessDCPower + residualPower - site.auxPower - flexiblePower
sitePower := state.gridPower + batteryPower + excessDCPower + residualPower - state.auxPower - flexiblePower
// handle priority
var flexStr string
@ -1102,7 +1144,13 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
site.log.DEBUG.Printf("site power: %.0fW"+flexStr, sitePower)
return sitePower, batteryBuffered, batteryStart, priorityAdjustment, nil
return sitePowerResult{
state: state,
power: sitePower,
batteryBuffered: batteryBuffered,
batteryStart: batteryStart,
priorityAdjustment: priorityAdjustment,
}
}
// updateLoadpoints updates all loadpoints' charge power
@ -1171,46 +1219,51 @@ func (site *Site) update(lp updater) {
// update loadpoints
totalChargePower := site.updateLoadpoints(consumption)
// update all circuits' power and currents
if site.circuit != nil {
if err := site.circuit.Update(site.loadpointsAsCircuitDevices()); err != nil {
site.updateCircuits()
site.applyHemsLimits()
if state, err := site.updateMeters(); err != nil {
site.log.ERROR.Println(err)
} else {
go site.optimizerUpdateAsync(tariff.SlotDuration)
site.updatePower(lp, state, totalChargePower, consumption, feedin)
}
site.publishCircuits()
}
if site.hems != nil {
var wg sync.WaitGroup
wg.Go(func() {
if dim := hems.Dimmed(site.hems); dim != nil {
if err := site.dimMeters(*dim); err != nil {
site.log.ERROR.Println(err)
}
}
})
wg.Go(func() {
if hems.Curtailed(site.hems) != nil {
if err := site.curtailPV(site.hems.CurtailedPercent()); err != nil {
site.log.ERROR.Println(err)
}
}
})
wg.Wait()
// smart grid charging
rate := site.currentRate(consumption)
// update battery after reading meters to ensure that (modbus) connection is open
batteryGridChargeActive := site.batteryGridChargeActive(rate)
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
site.updateBatteryMode(batteryGridChargeActive, rate)
// re-evaluate against the updated loadpoint state
site.publishSuggestions()
site.stats.Update(site)
}
// updatePower calculates the site power balance and updates the given loadpoint
func (site *Site) updatePower(lp updater, state siteState, totalChargePower float64, consumption, feedin api.Rates) {
// prioritize if possible
var flexiblePower float64
if lp != nil && lp.GetMode() == api.ModePV {
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
}
if sitePower, batteryBuffered, batteryStart, priorityAdjustment, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
res := site.sitePower(state, totalChargePower, flexiblePower)
state = res.state
// retain the estimates for the api getters and the green share
if site.gridMeter == nil || site.pvMeters == nil {
site.Lock()
site.gridPower, site.pvPower = state.gridPower, state.pvPower
site.Unlock()
}
// ignore negative pvPower values as that means it is not an energy source but consumption
homePower := site.gridPower + max(0, site.pvPower) + site.battery.Power - totalChargePower
homePower := state.gridPower + max(0, state.pvPower) + state.battery.Power - totalChargePower
homePower = max(homePower, 0)
site.publish(keys.HomePower, homePower)
@ -1222,23 +1275,25 @@ func (site *Site) update(lp updater) {
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints
// fix for: https://github.com/evcc-io/evcc/issues/11032
nonChargePower := homePower + max(0, -site.battery.Power)
nonChargePower := homePower + max(0, -state.battery.Power)
greenShareHome := site.greenShare(0, homePower)
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
// TODO
if lp != nil {
sitePower := res.power
// reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
sitePower += site.reservedPVPower(lp)
// battery boost deliberately drains the battery, hence battery priority
// below prioritySoc does not apply to the boosting loadpoint (#30541)
if lp.GetBatteryBoost() != boostDisabled {
sitePower += priorityAdjustment
sitePower += res.priorityAdjustment
}
lp.Update(
sitePower, site.battery.Power, consumption, feedin, batteryBuffered, batteryStart,
sitePower, state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
hems.Dimmed(site.hems),
)
@ -1249,33 +1304,26 @@ func (site *Site) update(lp updater) {
if telemetry.Enabled() && totalChargePower > standbyPower {
go telemetry.UpdateChargeProgress(site.log, totalChargePower, greenShareLoadpoints)
}
} else {
site.log.ERROR.Println(err)
}
// smart grid charging
rate, err := consumption.At(time.Now())
if consumption != nil && err != nil {
// currentRate returns the rate for the current time, warning if the rates don't cover it
func (site *Site) currentRate(rates api.Rates) api.Rate {
rate, err := rates.At(time.Now())
if rates == nil || err == nil {
return rate
}
msg := fmt.Sprintf("no matching rate for: %s", time.Now().Format(time.RFC3339))
if len(consumption) > 0 {
msg += fmt.Sprintf(", %d consumption rates (%s to %s)", len(consumption),
consumption[0].Start.Local().Format(time.RFC3339),
consumption[len(consumption)-1].End.Local().Format(time.RFC3339),
if len(rates) > 0 {
msg += fmt.Sprintf(", %d consumption rates (%s to %s)", len(rates),
rates[0].Start.Local().Format(time.RFC3339),
rates[len(rates)-1].End.Local().Format(time.RFC3339),
)
}
site.log.WARN.Println("planner:", msg)
}
// update battery after reading meters to ensure that (modbus) connection is open
batteryGridChargeActive := site.batteryGridChargeActive(rate)
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
site.updateBatteryMode(batteryGridChargeActive, rate)
// re-evaluate against the updated loadpoint state
site.publishSuggestions()
site.stats.Update(site)
return rate
}
// prepare publishes initial values

View file

@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"slices"
"sync"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/modbus"
"github.com/samber/lo"
@ -23,6 +25,46 @@ type circuitStruct struct {
MaxCurrent float64 `json:"maxCurrent,omitempty"`
}
// updateCircuits updates all circuits' power and currents
func (site *Site) updateCircuits() {
if site.circuit == nil {
return
}
if err := site.circuit.Update(site.loadpointsAsCircuitDevices()); err != nil {
site.log.ERROR.Println(err)
}
site.publishCircuits()
}
// applyHemsLimits applies the HEMS dim and curtail state to the site's devices
func (site *Site) applyHemsLimits() {
if site.hems == nil {
return
}
var wg sync.WaitGroup
wg.Go(func() {
if dim := hems.Dimmed(site.hems); dim != nil {
if err := site.dimMeters(*dim); err != nil {
site.log.ERROR.Println(err)
}
}
})
wg.Go(func() {
if hems.Curtailed(site.hems) != nil {
if err := site.curtailPV(site.hems.CurtailedPercent()); err != nil {
site.log.ERROR.Println(err)
}
}
})
wg.Wait()
}
// publishCircuits returns a list of circuit titles
func (site *Site) publishCircuits() {
cc := config.Circuits().Devices()

View file

@ -228,6 +228,14 @@ func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) {
site.suggestions = suggestions
}
// setBatteryForecast replaces the battery forecast of the cached state
func (site *Site) setBatteryForecast(forecast *types.BatteryForecast) {
site.Lock()
defer site.Unlock()
site.battery.Forecast = forecast
}
// suggestion returns the optimizer suggestion for the given device key.
// The actionable flag is evaluated on read against the device's current
// action since that changes between optimizer runs.
@ -264,7 +272,7 @@ func (site *Site) publishSuggestions() {
// optimizer result is stale
func (site *Site) clearSuggestions() {
site.setSuggestions(nil)
site.battery.Forecast = nil
site.setBatteryForecast(nil)
site.publishBattery()
site.publishSuggestions()
@ -391,7 +399,7 @@ func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
}
}()
err = site.optimizerUpdate(site.battery.Devices)
err = site.optimizerUpdate(site.state().battery.Devices)
}
// optimizerRequest assembles the optimizer request and the matching device
@ -668,7 +676,7 @@ func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details
site.publish("evopt-batteries", batteries)
site.setSuggestions(suggestions)
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, res.Batteries)
site.setBatteryForecast(site.addBatteryForecastTotals(req.Batteries, res.Batteries))
site.publishBattery()

View file

@ -63,7 +63,9 @@ func forecastRates(rr api.Rates) forecastSeries {
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo
// the consumption below powerFrom will get the available green power first
func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
greenPower := math.Max(0, site.pvPower) + math.Max(0, site.battery.Power)
state := site.state()
greenPower := math.Max(0, state.pvPower) + math.Max(0, state.battery.Power)
greenPowerAvailable := math.Max(0, greenPower-powerFrom)
power := powerTo - powerFrom

View file

@ -8,6 +8,7 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
@ -57,13 +58,14 @@ func TestSitePowerPriorityAdjustment(t *testing.T) {
batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)},
prioritySoc: prioritySoc,
}
site.excessDCPower = tc.excessDC
state, err := site.updateMeters()
require.NoError(t, err)
state.excessDCPower = tc.excessDC
sitePower, _, _, adjustment, err := site.sitePower(0, 0)
assert.NoError(t, err)
assert.Equal(t, tc.expSitePower, sitePower, "sitePower")
assert.Equal(t, tc.expAdjustment, adjustment, "priority adjustment")
assert.Equal(t, tc.expReconstructed, sitePower+adjustment, "reconstructed (unadjusted) site power")
res := site.sitePower(state, 0, 0)
assert.Equal(t, tc.expSitePower, res.power, "sitePower")
assert.Equal(t, tc.expAdjustment, res.priorityAdjustment, "priority adjustment")
assert.Equal(t, tc.expReconstructed, res.power+res.priorityAdjustment, "reconstructed (unadjusted) site power")
})
}
}
@ -160,11 +162,13 @@ func TestGreenShare(t *testing.T) {
t.Log(tc.title)
s := &Site{
siteState: siteState{
gridPower: tc.grid,
pvPower: tc.pv,
battery: types.BatteryState{
Power: tc.battery,
},
},
}
totalPower := tc.grid + tc.pv + max(0, tc.battery)