Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/fork_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"testing"

"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/db"
"github.com/evcc-io/evcc/db/settings"
)
Expand All @@ -17,6 +18,12 @@ func noSettingsDB(t *testing.T) {
t.Cleanup(func() { db.Instance = prev })
}

// setBatterySoc sets the soc as a successful read of the battery does
func setBatterySoc(site *Site, soc float64) {
site.battery.Soc = soc
site.battery.Devices = []types.Measurement{{Soc: &soc}}
}

// keepSettings restores the settings store after the test: keys it added are
// removed, values it changed are set back
func keepSettings(t *testing.T) {
Expand Down
29 changes: 23 additions & 6 deletions core/lm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ charge power has to fit into the circuit, against its power and its current
limit (the current spread over the phases set under *Erweitert*). Without a
known charge power, grid charging on a circuit stays off and a warning is
logged once. With a charge power entity, evcc writes the grid charge power
instead: the expected power, trimmed to what fits below the peak limit and into
the circuit, at least 500 W.
instead: the expected power, trimmed to what fits into the rest budget of the
running 15 minute window (at most below the peak limit) and into the circuit,
at least 500 W.
After a shed, grid charging waits for the hold-off, as stopping it frees exactly
the power that would let it start again. See `core/site_lm.go`.

Expand Down Expand Up @@ -212,13 +213,18 @@ applies. Survives a restart halfway.
*Einmalig laden* (battery page): charges once up to a soc and switches itself
off, right away or by a time at the cheapest slots before it (evcc's planner on
the planner tariff; right away once the time passed or the duration is
unknown). Cancelled when the battery is removed. A round power button starts it
unknown, but an order more than 2 h past its time is ended, also after a
restart). Cancelled when the battery is removed. A round power button starts it
(outlined) and stops it (filled while running). See `core/site_lm_once.go`.

Both pass the same gate as price-based grid charging: the circuit
([2](#2-battery-in-load-management)) and a running peak
([11](#11-peak-shaving)).

All of this acts only from the first soc read: until a battery returned one
(after the start, or while every read fails) the soc-based charging, the order
and peak shaving's reserve keep their state instead of deciding on a soc of 0.

```
POST /api/batterysocgridcharge/{true|false}
POST /api/batterysocgridchargestart/{soc}
Expand Down Expand Up @@ -280,8 +286,10 @@ the rest of the window (`peakShavingSource`).

Below the reserve the battery is kept in normal mode, as hold would block the
discharge controller; a battery mode set from outside through the api stays.
While the demand without the battery exceeds the limit, grid charging pauses
and stays off for the hold-off, so it cannot add to the peak.
While the demand without the battery exceeds the limit, or the grid power the
running window still allows if that is less (an earlier peak used up its
budget), grid charging pauses and stays off for the hold-off, so it cannot add
to the peak.

Without meter values for over 2 minutes the free value is written and grid
charging pauses until they are back: the battery covers any demand by ordinary
Expand Down Expand Up @@ -524,6 +532,9 @@ matches what the fork will do (`core/site_optimizer_lm.go`):
grid charge power with the charging efficiency; with peak shaving only with
the room below the limit, paused while a peak runs.
- one-time grid charging: the target as goal at the chosen time, or right away.
- a goal is set for step 1 at the earliest, the optimizer ignores one in step 0
(a goal reached within the current step, an order past its time). The rule is
pinned by `TestLmOptimizerScenarios/goal_soc_binds_from_step_1`.
- grid charging refused right now (shed hold-off, unknown charge power on a
circuit) is not offered (`charge_from_grid`).
- load management: a loadpoint plans with at most its circuits' power,
Expand Down Expand Up @@ -919,7 +930,13 @@ and `server`; many replay logged situations. Two optimizer tests only run
against a running optimizer: `TestLmOptimizerScenarios` (`OPTIMIZER_URI`)
solves synthetic days and checks soc bounds, import limit, energy balance and
goals; `TestLmOptimizerReplay` (`OPTIMIZER_REPLAY`, `OPTIMIZER_URI`) replays a
recorded request.
recorded request. Without `OPTIMIZER_URI` both are skipped, so a green `ok` of
`./core/` alone proves nothing about them. The scenarios include the contract
test of the optimizer rule "a goal binds from step 1, one in step 0 is
ignored" (`goal_soc_binds_from_step_1`); the weekly check of a new optimizer
image runs them (`WSLTEST_ENV="OPTIMIZER_URI=http://localhost:<port>"
bash testtools/wsltest.sh ./core/ -- -test.run TestLmOptimizerScenarios`), so a
changed rule shows up there.

Tests touching the settings store call `noSettingsDB` (keeps it in memory, as
evcc's `newDeleteTestSite` leaves its database open) and `keepSettings`
Expand Down
2 changes: 1 addition & 1 deletion core/lm/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type Decision struct {
const (
EventShed = "shed" // a running load was switched off, A = guard minutes, B = circuit power
EventThrottled = "throttled" // a load was limited, A = requested, B = allowed
EventGridChargePaused = "gridChargePaused" // a peak paused battery grid charging, A = demand, B = peak limit
EventGridChargePaused = "gridChargePaused" // a peak paused battery grid charging, A = demand, B = grid power allowed
EventGridChargeDenied = "gridChargeDenied" // the circuit denied battery grid charging, A = allowed, B = wanted
EventPeak = "peak" // the battery started to cover a peak, A = demand, B = peak limit
EventNotFollowing = "notFollowing" // a load keeps drawing more than allowed, A = power, B = allowed
Expand Down
24 changes: 21 additions & 3 deletions core/site_lm.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"fmt"
"maps"
"math"
"slices"
"sync"
"sync/atomic"
"time"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/lm"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/db/settings"
"github.com/evcc-io/evcc/util/config"
)
Expand Down Expand Up @@ -494,15 +496,28 @@ func (site *Site) batteryChargeSetpoint() float64 {
return power
}

// batterySocRead returns the battery soc and whether any battery returned one in
// its last read. Until then the soc is 0 (after the start) or stale (all reads
// fail): no decision may be taken from it.
func (site *Site) batterySocRead() (float64, bool) {
site.RLock()
defer site.RUnlock()

known := slices.ContainsFunc(site.battery.Devices, func(m types.Measurement) bool { return m.Soc != nil })

return site.battery.Soc, known
}

// batterySocChargeActive implements soc-based grid charging: charging starts at
// or below the start soc and continues until the stop soc is reached
// or below the start soc and continues until the stop soc is reached. Without a
// read soc the state stays as it was.
func (site *Site) batterySocChargeActive() bool {
if !site.batteryConfigured() {
return false
}

// read before locking, GetBatterySoc takes the site lock
soc := site.GetBatterySoc()
// read before locking, the site lock is taken
soc, known := site.batterySocRead()

s := site.lms()

Expand All @@ -513,6 +528,9 @@ func (site *Site) batterySocChargeActive() bool {
case !s.socChargeEnabled:
running = false

case !known:
// nothing read yet: keep running as it is

case s.socChargeStop > 0 && soc >= s.socChargeStop:
if running {
site.log.DEBUG.Printf("battery soc grid charge: stop soc reached (%.0f%% >= %.0f%%)", soc, s.socChargeStop)
Expand Down
34 changes: 33 additions & 1 deletion core/site_lm_once.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ func (site *Site) publishGridChargeOnce(o gridChargeOnce) {
// maxGridChargeOnceAhead is how far ahead the time of day may be
const maxGridChargeOnceAhead = 48 * time.Hour

// maxGridChargeOnceLate is how long after its time an order still charges right
// away: a restart or an outage in the night, or the peak gate holding the
// charging back until then. An order later than that is ended.
const maxGridChargeOnceLate = 2 * time.Hour

// onceExpired reports whether an order by a time is past it by more than
// maxGridChargeOnceLate
func onceExpired(o gridChargeOnce, now time.Time) bool {
return o.Target > 0 && !o.Until.IsZero() && now.After(o.Until.Add(maxGridChargeOnceLate))
}

func (site *Site) gridChargeOnce() gridChargeOnce {
s := site.lms()

Expand Down Expand Up @@ -81,11 +92,22 @@ func (site *Site) restoreGridChargeOnce() {
s.mu.Lock()
s.gridOnce = o
s.mu.Unlock()

if onceExpired(o, time.Now()) {
site.endExpiredGridChargeOnce()
}
}

site.publishGridChargeOnce(site.gridChargeOnce())
}

// endExpiredGridChargeOnce ends an order whose time is long past, so that it does
// not charge right away much later
func (site *Site) endExpiredGridChargeOnce() {
site.log.INFO.Println("battery grid charge once: time passed, cancelled")
site.setGridChargeOnce(gridChargeOnce{})
}

// SetBatteryGridChargeOnce starts one-time grid charging up to target soc, right
// away with a zero until, else at the cheapest slots before until
func (site *Site) SetBatteryGridChargeOnce(target float64, until time.Time) error {
Expand Down Expand Up @@ -144,7 +166,17 @@ func (site *Site) batteryGridChargeOnceActive() bool {
return false
}

soc := site.GetBatterySoc()
if onceExpired(o, time.Now()) {
site.endExpiredGridChargeOnce()
return false
}

// nothing to decide on before the first read: the order carries on as stored
soc, known := site.batterySocRead()
if !known {
return o.Active
}

if soc >= o.Target {
site.log.INFO.Printf("battery grid charge once: %.0f%% reached", o.Target)
site.setGridChargeOnce(gridChargeOnce{})
Expand Down
49 changes: 47 additions & 2 deletions core/site_lm_once_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func onceSite(t *testing.T, soc float64) *Site {
log: util.NewLogger("test"),
batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(&capacityBattery{kWh: 10}))},
}
site.battery.Soc = soc
setBatterySoc(site, soc)
site.peak().chargePower = 5000
return site
}
Expand All @@ -53,7 +53,7 @@ func TestGridChargeOnceRightAway(t *testing.T) {
restarted.restoreGridChargeOnce()
assert.Equal(t, 80.0, restarted.gridChargeOnce().Target)

site.battery.Soc = 80
setBatterySoc(site, 80)
assert.False(t, site.batteryGridChargeRequested(api.Rate{}))
assert.Zero(t, site.gridChargeOnce().Target, "ended at the target")
}
Expand Down Expand Up @@ -113,3 +113,48 @@ func TestGridChargeOnceOptimizerGoal(t *testing.T) {
require.Len(t, goal, 16)
assert.Equal(t, float32(8000), goal[3], "48 min at 5 kW: the 4th quarter hour")
}

// An order whose time is long past is ended after a restart and in the cycle; one
// that is only late still charges right away (the peak gate may have held it back).
func TestGridChargeOnceExpired(t *testing.T) {
noSettingsDB(t)

now := time.Now()
assert.True(t, onceExpired(gridChargeOnce{Target: 80, Until: now.Add(-50 * time.Hour)}, now))
assert.True(t, onceExpired(gridChargeOnce{Target: 80, Until: now.Add(-maxGridChargeOnceLate - time.Minute)}, now))
assert.False(t, onceExpired(gridChargeOnce{Target: 80, Until: now.Add(-10 * time.Minute)}, now))
assert.False(t, onceExpired(gridChargeOnce{Target: 80, Until: now.Add(time.Hour)}, now))
assert.False(t, onceExpired(gridChargeOnce{Target: 80}, now), "right away has no time")
assert.False(t, onceExpired(gridChargeOnce{Until: now.Add(-50 * time.Hour)}, now), "no order")

restore := func(until time.Duration) *Site {
site := onceSite(t, 40)
settings.SetJson(keys.BatteryGridChargeOnce, gridChargeOnce{Target: 80, Until: time.Now().Add(until), Active: true})
site.restoreGridChargeOnce()
return site
}

site := restore(-50 * time.Hour)
assert.Zero(t, site.gridChargeOnce().Target, "ended after the restart")

var stored gridChargeOnce
require.NoError(t, settings.Json(keys.BatteryGridChargeOnce, &stored))
assert.Zero(t, stored.Target, "and not restored again")

site = restore(-10 * time.Minute)
assert.Equal(t, 80.0, site.gridChargeOnce().Target, "late: carries on")
assert.True(t, site.batteryGridChargeOnceActive(), "late: right away")

// in the cycle
site = onceSite(t, 40)
site.lms().gridOnce = gridChargeOnce{Target: 80, Until: time.Now().Add(-50 * time.Hour), Active: true}
assert.False(t, site.batteryGridChargeOnceActive())
assert.Zero(t, site.gridChargeOnce().Target, "ended in the cycle")

// no soc read yet: the time decides all the same
site = onceSite(t, 40)
site.battery.Devices = nil
site.lms().gridOnce = gridChargeOnce{Target: 80, Until: time.Now().Add(-50 * time.Hour)}
assert.False(t, site.batteryGridChargeOnceActive())
assert.Zero(t, site.gridChargeOnce().Target)
}
72 changes: 72 additions & 0 deletions core/site_lm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"testing"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/lm"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -67,3 +70,72 @@ func TestBatteryNotFollowing(t *testing.T) {
}
assert.True(t, found, "event for the overview")
}

// TestSocUnknownKeepsState verifies that nothing is decided from a soc that was
// never read (0 after the start) or whose reads all fail: the hysteresis, the
// one-time order and the peak reserve stay as they were
func TestSocUnknownKeepsState(t *testing.T) {
noSettingsDB(t)
defer settings.SetBool(keys.BatterySocGridChargeRunning, false)

bat := config.NewStaticDevice(config.Named{Name: "bat"}, api.Meter(&capacityBattery{kWh: 10}))
site := &Site{log: util.NewLogger("test"), batteryMeters: []config.Device[api.Meter]{bat}}
site.lms().socChargeEnabled = true

// start: 0 would be below the start soc, but nothing was read
assert.False(t, site.batterySocChargeActive(), "first cycle without soc")
assert.False(t, site.lms().socChargeRunning)

setBatterySoc(site, 50)
assert.False(t, site.batterySocChargeActive(), "the real soc lies above the start soc")

// charging halfway: a failing read neither stops nor restarts it
setBatterySoc(site, 30)
site.lms().socChargeRunning = true
site.battery.Devices = []types.Measurement{{}}
site.battery.Soc = 90 // retained from before, above the stop soc
assert.True(t, site.batterySocChargeActive(), "kept while no soc is read")

setBatterySoc(site, 90)
assert.False(t, site.batterySocChargeActive(), "stop soc read")

// the switch still switches off without a soc
site.lms().socChargeRunning = true
site.battery.Devices = nil
site.lms().socChargeEnabled = false
assert.False(t, site.batterySocChargeActive(), "switched off")

// one-time order: not planned from 0 %, carries on as stored
site.battery.Soc = 0
site.lms().gridOnce = gridChargeOnce{Target: 80}
assert.False(t, site.batteryGridChargeOnceActive(), "not active yet, no soc to charge from")
assert.Equal(t, 80.0, site.gridChargeOnce().Target, "the order stays")
assert.False(t, site.gridChargeOnce().Active)

site.lms().gridOnce = gridChargeOnce{Target: 80, Active: true}
assert.True(t, site.batteryGridChargeOnceActive(), "charging carries on")
setBatterySoc(site, 85)
assert.False(t, site.batteryGridChargeOnceActive(), "target read")
assert.Zero(t, site.gridChargeOnce().Target)
}

// TestPeakReserveUnknownSoc verifies that the peak reserve is not entered at a
// soc that was never read
func TestPeakReserveUnknownSoc(t *testing.T) {
noSettingsDB(t)
sc := newScenario(t)
site := sc.site
site.battery.Soc = 0
site.battery.Devices = nil
site.updatePeakShaving(site.state())
assert.False(t, site.peakShavingActive(), "reserve not entered at an unread soc")

setBatterySoc(site, 10)
site.updatePeakShaving(site.state())
assert.True(t, site.peakShavingActive(), "reserve entered at the read soc")

site.battery.Devices = nil
site.battery.Soc = 90
site.updatePeakShaving(site.state())
assert.True(t, site.peakShavingActive(), "state kept while the soc is not read")
}
Loading
Loading