Site: separate measurement from power calculation in update cycle (#32770)
This commit is contained in:
parent
e7b694e550
commit
a578893edd
5 changed files with 276 additions and 172 deletions
282
core/site.go
282
core/site.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue