diff --git a/core/fork_helpers_test.go b/core/fork_helpers_test.go index fb63176c27..c5ad859148 100644 --- a/core/fork_helpers_test.go +++ b/core/fork_helpers_test.go @@ -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" ) @@ -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) { diff --git a/core/lm/README.md b/core/lm/README.md index b853062199..094ad0abcf 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -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`. @@ -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} @@ -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 @@ -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, @@ -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:" +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` diff --git a/core/lm/status.go b/core/lm/status.go index 560e43b360..3600d2f4a1 100644 --- a/core/lm/status.go +++ b/core/lm/status.go @@ -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 diff --git a/core/site_lm.go b/core/site_lm.go index bb363aa87b..ef75a8b6cb 100644 --- a/core/site_lm.go +++ b/core/site_lm.go @@ -18,6 +18,7 @@ import ( "fmt" "maps" "math" + "slices" "sync" "sync/atomic" "time" @@ -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" ) @@ -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() @@ -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) diff --git a/core/site_lm_once.go b/core/site_lm_once.go index f89b818365..1ae0714084 100644 --- a/core/site_lm_once.go +++ b/core/site_lm_once.go @@ -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() @@ -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 { @@ -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{}) diff --git a/core/site_lm_once_test.go b/core/site_lm_once_test.go index 3edb017b29..e48a4ba303 100644 --- a/core/site_lm_once_test.go +++ b/core/site_lm_once_test.go @@ -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 } @@ -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") } @@ -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) +} diff --git a/core/site_lm_test.go b/core/site_lm_test.go index 29173ed22b..8ddd9e8980 100644 --- a/core/site_lm_test.go +++ b/core/site_lm_test.go @@ -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" ) @@ -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") +} diff --git a/core/site_optimizer_lm.go b/core/site_optimizer_lm.go index 4f2f372f52..3dd85875e0 100644 --- a/core/site_optimizer_lm.go +++ b/core/site_optimizer_lm.go @@ -169,7 +169,12 @@ func (site *Site) applyLmBatteryInputs(bat *optimizer.BatteryConfig, req *optimi bat.ChargeFromGrid = false } + // the optimizer binds a goal from step 1 on and ignores one in step 0, so a + // goal the battery reaches within the current step is set for step 1 setGoal := func(i int, goal float32) { + if i = max(i, firstGoalStep); i >= len(dt) { + return + } if len(bat.SGoal) != len(dt) { bat.SGoal = make([]float32, len(dt)) } @@ -202,6 +207,8 @@ func (site *Site) applyLmBatteryInputs(bat *optimizer.BatteryConfig, req *optimi // only what the room below the limit allows. if o := site.gridChargeOnce(); o.Target > 0 && bat.ChargeFromGrid { if goal := min(wh(o.Target), top); goal > bat.SInitial { + // right away, also once the time has passed + rightAway := o.Until.IsZero() || time.Until(o.Until) <= 0 d := time.Until(o.Until) if o.Until.IsZero() { d = site.onceRequiredDuration(o.Target, float64(bat.SInitial/bat.SCapacity*100)) @@ -210,10 +217,9 @@ func (site *Site) applyLmBatteryInputs(bat *optimizer.BatteryConfig, req *optimi d = time.Duration(float64(goal-bat.SInitial) / (float64(bat.CMax) * site.identChargeEta()) * float64(time.Hour)) } } - i := slotAfter(dt, max(d, 0)) + i := max(slotAfter(dt, max(d, 0)), firstGoalStep) if plan.limit > 0 { - // right away, also once the time has passed - if o.Until.IsZero() || d <= 0 { + if rightAway { if j := plan.chargeSlot(req.TimeSeries, *bat, 0, bat.SInitial, goal, nil); j >= 0 { i = max(i, j) } else { @@ -274,6 +280,9 @@ func setLevel(goals []float32, n, i int, v float32) []float32 { return goals } +// firstGoalStep is the first time step the optimizer binds a goal soc in +const firstGoalStep = 1 + // slotAfter returns the index of the time step in which d has passed func slotAfter(dt []int, d time.Duration) int { var elapsed time.Duration diff --git a/core/site_optimizer_lm_test.go b/core/site_optimizer_lm_test.go index d805fd89ec..5b1a15da12 100644 --- a/core/site_optimizer_lm_test.go +++ b/core/site_optimizer_lm_test.go @@ -413,3 +413,59 @@ func TestSocPass(t *testing.T) { k, _ = site.socPassStart(req, home, res) assert.Equal(t, -1, k, "switched off") } + +// The optimizer ignores a goal in step 0, so a goal the battery reaches within +// the current step goes into step 1. Without peak shaving. +func TestLmOptimizerGoalNotInStepZero(t *testing.T) { + config.Reset() + t.Cleanup(config.Reset) + + apply := func(site *Site, soc float32) []float32 { + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(16)}} + batteries := []optimizerBattery{homeBattery()} + batteries[0].cfg.SInitial = soc + site.applyLmOptimizerInputs(&req, batteries) + return batteries[0].cfg.SGoal + } + + t.Run("one-time, time passed without reaching the target", func(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + site.lms().gridOnce = gridChargeOnce{Target: 90, Until: time.Now().Add(-time.Minute)} + + goal := apply(site, 5000) + require.Len(t, goal, 16) + assert.Zero(t, goal[0]) + assert.Equal(t, float32(9000), goal[1], "as soon as the optimizer binds it") + }) + + t.Run("one-time, right away with a small rest", func(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + site.lms().gridOnce = gridChargeOnce{Target: 80} + + goal := apply(site, 7900) + require.Len(t, goal, 16) + assert.Zero(t, goal[0]) + assert.Equal(t, float32(8000), goal[1]) + }) + + t.Run("running soc charging, stop reached within the step", func(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + s := site.lms() + s.socChargeEnabled, s.socChargeStart, s.socChargeStop, s.socChargeRunning = true, 20, 80, true + + goal := apply(site, 7950) + require.Len(t, goal, 16) + assert.Zero(t, goal[0]) + assert.Equal(t, float32(8000), goal[1]) + }) + + t.Run("a horizon of one step has no goal to set", func(t *testing.T) { + site := &Site{log: util.NewLogger("test")} + site.lms().gridOnce = gridChargeOnce{Target: 80} + + req := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: quarterHours(1)}} + batteries := []optimizerBattery{homeBattery()} + assert.NotPanics(t, func() { site.applyLmOptimizerInputs(&req, batteries) }) + assert.Empty(t, batteries[0].cfg.SGoal) + }) +} diff --git a/core/site_optimizer_scenarios_test.go b/core/site_optimizer_scenarios_test.go index 57e49816bf..87f92fef78 100644 --- a/core/site_optimizer_scenarios_test.go +++ b/core/site_optimizer_scenarios_test.go @@ -427,6 +427,29 @@ func TestLmOptimizerScenarios(t *testing.T) { assert.InDelta(t, scenarioCapacity*0.9, r.req.Batteries[0].SGoal[7], 1) }) + // contract: the optimizer ignores a goal in step 0 and binds one from step 1 on, + // which is why the fork never sets one in step 0 (applyLmBatteryInputs) + t.Run("goal soc binds from step 1", func(t *testing.T) { + solve := func(goalStep int) scenarioResult { + req, batteries := winter.request(30) + if goalStep >= 0 { + batteries[0].cfg.SGoal = make([]float32, len(req.TimeSeries.Dt)) + batteries[0].cfg.SGoal[goalStep] = batteries[0].cfg.SInitial + 1000 + } + finish(&req, batteries) + return solveReq(t, req) + } + + base := solve(-1) + step0 := solve(0) + step1 := solve(1) + + goal := step1.req.Batteries[0].SGoal[1] + assert.Less(t, base.soc(0)[1], goal-1, "without a goal the battery does not get there") + assert.InDeltaSlice(t, base.soc(0), step0.soc(0), 1, "a goal in step 0 changes nothing") + assert.GreaterOrEqual(t, step1.soc(0)[1], goal-1, "a goal in step 1 binds") + }) + // by a time: at the cheapest slots before it t.Run("once by a time, cheap night", func(t *testing.T) { cheap := func(i int) bool { return i >= 16 && i < 28 } // 22:00-01:00 diff --git a/core/site_peakshaving.go b/core/site_peakshaving.go index 38a027f474..368e762f2f 100644 --- a/core/site_peakshaving.go +++ b/core/site_peakshaving.go @@ -572,12 +572,15 @@ func (site *Site) updatePeakShaving(state siteState) { return } - soc := site.GetBatterySoc() + soc, known := site.batterySocRead() hyst := site.peakHysteresis() s.mu.Lock() - // below the reserve the battery is for peaks only - s.shaving = peak.Reserved(s.shaving, soc, reserve, hyst) + // below the reserve the battery is for peaks only. Without a read soc (start, + // all reads failing) the state stays as it was. + if known { + s.shaving = peak.Reserved(s.shaving, soc, reserve, hyst) + } shaving := s.shaving s.mu.Unlock() @@ -905,10 +908,10 @@ func (site *Site) peakPausesGridCharge() bool { return true } - if s.demand > s.limit { + if limit := s.chargeLimit(now); s.demand > limit { if !now.Before(s.chargePause) { - site.log.DEBUG.Printf("battery grid charge: paused, demand %.0fW exceeds the %.0fW peak limit", s.demand, s.limit) - site.lmm().AddEvent(lm.Event{At: now, Type: lm.EventGridChargePaused, A: s.demand, B: s.limit}) + site.log.DEBUG.Printf("battery grid charge: paused, demand %.0fW exceeds the %.0fW allowed (peak limit %.0fW)", s.demand, limit, s.limit) + site.lmm().AddEvent(lm.Event{At: now, Type: lm.EventGridChargePaused, A: s.demand, B: limit}) } s.chargePause = now.Add(site.lmHoldOff()) return true @@ -956,9 +959,25 @@ func (site *Site) peakCheckMeters() { site.handBackPeak() } -// peakChargeHeadroom returns how much grid charge power fits below the peak -// limit on top of the current demand. ok is false while peak shaving is off, -// there is no limit to fit under then. +// chargeLimit is the grid power grid charging has to stay under: the peak limit, +// or what the running 15 minute window still allows if that is less, as an +// earlier peak in the window has used up its budget and charging on top would +// push the window over the limit. Without a metered current window (before its +// first sample, meters failing) the peak limit alone. Called with mu held. +func (s *peakState) chargeLimit(now time.Time) float64 { + if s.window.Start.Equal(now.Truncate(peak.Window)) && !s.metersStale(now) { + // the allowed power is the limit up to rounding while the window is on budget + if allowed := s.window.Allowed; allowed < s.limit-1 { + return allowed + } + } + + return s.limit +} + +// peakChargeHeadroom returns how much grid charge power fits below the grid +// power the window allows (see chargeLimit) on top of the current demand. ok is +// false while peak shaving is off, there is no limit to fit under then. func (site *Site) peakChargeHeadroom() (headroom float64, ok bool) { s := site.peak() @@ -969,7 +988,7 @@ func (site *Site) peakChargeHeadroom() (headroom float64, ok bool) { return 0, false } - return max(0, s.limit-s.demand), true + return max(0, s.chargeLimit(s.clock.Now())-s.demand), true } // updateBatteryModePeakAware keeps the battery in normal mode while the reserve diff --git a/core/site_peakshaving_test.go b/core/site_peakshaving_test.go index 68115cea1a..7be6387239 100644 --- a/core/site_peakshaving_test.go +++ b/core/site_peakshaving_test.go @@ -551,3 +551,103 @@ func TestNumberWrite(t *testing.T) { }) } } + +// spikeThenHouse meters a window from its start: the car draws 6 kW for 8 +// minutes, then the house 500 W until 13 minutes +func spikeThenHouse(site *Site, clk *clock.Mock) { + site.setPeakGridEnergy(nil) + site.updatePeakWindow(6000, 0) // the first sample of the window + + for range 16 { + energyStep(site, clk, 6000, nil) + } + for range 10 { + energyStep(site, clk, 500, nil) + } +} + +// TestChargeSetpointWindowBudget replays a spike early in the window: a car draws +// 6 kW from the grid for 8 minutes, then the house needs 500 W. The window allows +// 833 W for the rest, so grid charging with the battery's 1800 W would end the +// window above the limit; it stays off until the window has room. +func TestChargeSetpointWindowBudget(t *testing.T) { + site, clk := peakWindowSite(t, 0) + + s := site.peak() + s.enabled = true + s.limit = 3500 + s.chargePower = 1800 + s.set = func(float64) error { return nil } + + spikeThenHouse(site, clk) + s.demand = 500 + + assert.InDelta(t, 833, s.window.Allowed, 1, "the window's rest") + assert.Zero(t, site.batteryChargeSetpoint(), "333 W of room is below the minimum") + assert.False(t, site.peakPausesGridCharge(), "the house alone fits") + + // a larger load: above what the window allows, though below the limit + s.demand = 1200 + assert.True(t, site.peakPausesGridCharge()) + assert.Zero(t, site.batteryChargeSetpoint()) + + // the charge power is trimmed to the room under the window's allowed power + s.chargePause = time.Time{} + s.demand = 100 + s.chargePower = 6250 + assert.False(t, site.peakPausesGridCharge()) + assert.Equal(t, 733.0, site.batteryChargeSetpoint(), "833 - 100 W") +} + +// A window with budget left over never allows more than the fixed limit. +func TestChargeSetpointWindowBudgetAboveLimit(t *testing.T) { + site, clk := peakWindowSite(t, 0) + + s := site.peak() + s.enabled = true + s.limit = 3500 + s.chargePower = 6250 + s.set = func(float64) error { return nil } + + site.setPeakGridEnergy(nil) + site.updatePeakWindow(500, 0) // the first sample of the window + for range 2 { + energyStep(site, clk, 500, nil) + } + s.demand = 500 + + assert.Greater(t, s.window.Allowed, 3500.0, "unused budget lifts the allowed power") + assert.Equal(t, 3000.0, site.batteryChargeSetpoint(), "limit - demand") + assert.False(t, site.peakPausesGridCharge()) + + s.demand = 3600 + assert.True(t, site.peakPausesGridCharge(), "above the limit") +} + +// Without a metered current window the fixed limit applies: before its first +// sample, and while the meters fail. +func TestChargeSetpointWindowStale(t *testing.T) { + site, clk := peakWindowSite(t, 0) + + s := site.peak() + s.enabled = true + s.limit = 3500 + s.chargePower = 1800 + s.set = func(float64) error { return nil } + + spikeThenHouse(site, clk) + s.demand = 500 + require.Zero(t, site.batteryChargeSetpoint(), "window used up") + + // the next window began, the cycle did not meter it yet + clk.Add(3 * time.Minute) + assert.Equal(t, 1800.0, site.batteryChargeSetpoint(), "the old window's budget no longer counts") + + // the meters failed: charging pauses, the headroom is the fixed one + clk.Set(time.Date(2026, 9, 25, 10, 13, 0, 0, time.UTC)) + s.updated = clk.Now().Add(-5 * time.Minute) + headroom, ok := site.peakChargeHeadroom() + assert.True(t, ok) + assert.Equal(t, 3000.0, headroom) + assert.True(t, site.peakPausesGridCharge()) +} diff --git a/core/site_scenario_test.go b/core/site_scenario_test.go index 0c685487f4..bf89880c11 100644 --- a/core/site_scenario_test.go +++ b/core/site_scenario_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/circuit" "github.com/evcc-io/evcc/util" @@ -79,6 +80,7 @@ func newScenario(t *testing.T) *scenario { lms.socChargeEnabled = true s := site.peak() + s.clock = clock.NewMock() // stands still at the start of a window, so its allowed power is the limit s.enabled = true s.chargePower = 6250 s.set = func(v float64) error { sc.peak = &v; return nil } @@ -119,7 +121,7 @@ func (sc *scenario) withDynamicCharge() { func (sc *scenario) cycle(soc, grid, battery float64) bool { site := sc.site - site.battery.Soc = soc + setBatterySoc(site, soc) site.battery.Power = battery site.gridPower = grid diff --git a/i18n/de.json b/i18n/de.json index bbab1626c5..657fa6ef40 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1765,7 +1765,7 @@ "enabledHelp": "Die Leistungsgrenzen der Stromkreise (Peak) drosseln Wallbox, Heizstäbe und Batterie-Netzladen.", "event": { "gridChargeDenied": "Netzladen gesperrt · Stromkreis erlaubt {allowed} von {wanted}", - "gridChargePaused": "Netzladen pausiert · Bedarf {demand} über Peak-Grenze {limit}", + "gridChargePaused": "Netzladen pausiert · Bedarf {demand} über erlaubtem Netzbezug {limit}", "notFollowing": "{load} folgt der Vorgabe nicht · {power} statt {allowed}", "peak": "Peak abgefangen · Bedarf {demand}, Grenze {limit}", "shed": "{load} abgeworfen · Stromkreis bei {power}", diff --git a/i18n/en.json b/i18n/en.json index 9e205bbc1b..ab348c7ee5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1765,7 +1765,7 @@ "enabledHelp": "The circuits' power limits (peak) throttle wallboxes, heaters and battery grid charging.", "event": { "gridChargeDenied": "Grid charging blocked · circuit allows {allowed} of {wanted}", - "gridChargePaused": "Grid charging paused · demand {demand} above peak limit {limit}", + "gridChargePaused": "Grid charging paused · demand {demand} above allowed grid draw {limit}", "notFollowing": "{load} ignores its limit · {power} instead of {allowed}", "peak": "Peak covered · demand {demand}, limit {limit}", "shed": "{load} shed · circuit at {power}",