Planner: fix repeating overrun (#26529)

This commit is contained in:
Michael Geers 2026-02-01 13:00:51 +01:00 • committed by GitHub
parent 33ce3a494f
commit 2671732c32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 139 additions and 16 deletions

View file

@ -152,6 +152,7 @@ type Loadpoint struct {
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
planOverrunSent bool // notification has been sent already
planLocked PlanLock // locked plan
// cached state
status api.ChargeStatus // Charger status
@ -528,6 +529,9 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
// session is persisted during evChargeStopHandler which runs before
lp.clearSession()
// clear locked plan goal on disconnect
lp.clearPlanLock()
// phases are unknown when vehicle disconnects
lp.ResetMeasuredPhases()
@ -984,8 +988,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
if !lp.socBasedPlanning() {
return false
}
_, _, id := lp.NextVehiclePlan()
return id > 1
return lp.getPlanId() > 1
}
// vehicleHasSoc returns true if active vehicle supports returning soc, i.e. it is not an offline vehicle

View file

@ -121,6 +121,8 @@ type API interface {
GetPlanEnergy() (time.Time, float64)
// SetPlanEnergy sets the charge plan energy
SetPlanEnergy(time.Time, float64) error
// ClearPlanLock clears the locked plan goal
ClearPlanLock()
// GetPlanGoal returns the plan goal and if the goal is soc based
GetPlanGoal() (float64, bool)
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state

View file

@ -55,6 +55,18 @@ func (mr *MockAPIMockRecorder) ActivePhases() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePhases", reflect.TypeOf((*MockAPI)(nil).ActivePhases))
}
// ClearPlanLock mocks base method.
func (m *MockAPI) ClearPlanLock() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "ClearPlanLock")
}
// ClearPlanLock indicates an expected call of ClearPlanLock.
func (mr *MockAPIMockRecorder) ClearPlanLock() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearPlanLock", reflect.TypeOf((*MockAPI)(nil).ClearPlanLock))
}
// EffectiveLimitSoc mocks base method.
func (m *MockAPI) EffectiveLimitSoc() int {
m.ctrl.T.Helper()

View file

@ -356,6 +356,9 @@ func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) {
// setPlanEnergy sets plan target energy (no mutex)
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
// clear locked goal when energy plan changes
lp.clearPlanLock()
lp.planEnergy = energy
lp.publish(keys.PlanEnergy, energy)
lp.settings.SetFloat(keys.PlanEnergy, energy)

View file

@ -61,15 +61,15 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
return nil
}
// NextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
lp.RLock()
defer lp.RUnlock()
return lp.nextVehiclePlan()
}
// nextVehiclePlan returns the next vehicle plan time, soc, id
// Returns locked plan if available, otherwise calculates fresh
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
// return locked plan if available
if p := lp.planLocked; p.Id > 0 {
return p.Time, p.Soc, p.Id
}
// calculate fresh plan
if v := lp.GetVehicle(); v != nil {
var plans []plan
@ -103,31 +103,41 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
// EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int {
_, soc, _ := lp.NextVehiclePlan()
lp.RLock()
defer lp.RUnlock()
_, soc, _ := lp.nextVehiclePlan()
return soc
}
// EffectivePlanId returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int {
// getPlanId returns the plan id of the current/next plan
func (lp *Loadpoint) getPlanId() int {
if lp.socBasedPlanning() {
_, _, id := lp.NextVehiclePlan()
_, _, id := lp.nextVehiclePlan()
return id
}
if lp.planEnergy > 0 {
return 1
}
// no plan
return 0
}
// EffectivePlanId returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int {
lp.RLock()
defer lp.RUnlock()
return lp.getPlanId()
}
// EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time {
lp.RLock()
defer lp.RUnlock()
if lp.socBasedPlanning() {
ts, _, _ := lp.NextVehiclePlan()
ts, _, _ := lp.nextVehiclePlan()
return ts
}
ts, _ := lp.GetPlanEnergy()
ts, _ := lp.getPlanEnergy()
return ts
}

View file

@ -126,3 +126,38 @@ func TestNextPlan(t *testing.T) {
assert.Equal(t, tc.planId, res.Id)
}
}
func TestPlanLocking(t *testing.T) {
clk := clock.NewMock()
now := clk.Now()
lp := NewLoadpoint(util.NewLogger("foo"), nil)
lp.clock = clk
planTime := now.Add(2 * time.Hour)
t.Run("lock and unlock", func(t *testing.T) {
lp.lockPlanGoal(planTime, 80, 2)
// locked values returned before plan target
ts, soc, id := lp.nextVehiclePlan()
assert.Equal(t, planTime, ts)
assert.Equal(t, 80, soc)
assert.Equal(t, 2, id)
clk.Add(3 * time.Hour) // advance past plan target
// locked values persist during overrun
ts, soc, id = lp.nextVehiclePlan()
assert.Equal(t, planTime, ts)
assert.Equal(t, 80, soc)
assert.Equal(t, 2, id)
// after clearing, lock is not returned
lp.clearPlanLock()
ts, soc, id = lp.nextVehiclePlan()
assert.True(t, ts.IsZero())
assert.Equal(t, 0, soc)
assert.Equal(t, 0, id)
})
}

View file

@ -14,11 +14,40 @@ import (
// TODO planActive is not guarded by mutex
// PlanLock contains information about a locked plan
type PlanLock struct {
Time time.Time // target time (committed goal, persists during overrun)
Soc int // target soc
Id int // id (0=none, 1=static, 2+=repeating), needed to highlight the plan in ui
}
// clearPlanLock clears the locked plan goal
func (lp *Loadpoint) clearPlanLock() {
lp.planLocked = PlanLock{}
}
// ClearPlanLock clears the locked plan goal
func (lp *Loadpoint) ClearPlanLock() {
lp.Lock()
defer lp.Unlock()
lp.clearPlanLock()
}
// lockPlanGoal locks the current plan goal to handle overruns (soc-based plans)
func (lp *Loadpoint) lockPlanGoal(planTime time.Time, soc int, id int) {
lp.planLocked = PlanLock{
Time: planTime,
Soc: soc,
Id: id,
}
}
// setPlanActive updates plan active flag
func (lp *Loadpoint) setPlanActive(active bool) {
if !active {
lp.planOverrunSent = false
lp.planSlotEnd = time.Time{}
lp.clearPlanLock()
}
if lp.planActive != active {
lp.planActive = active
@ -177,6 +206,11 @@ func (lp *Loadpoint) plannerActive() (active bool) {
return false
}
// lock the goal when soc-based plan becomes active for the first time
if lp.planLocked.Id == 0 && isSocBased {
lp.lockPlanGoal(planTime, int(goal), lp.getPlanId())
}
// remember last active plan's slot end time
lp.planSlotEnd = activeSlot.End
} else if lp.planActive {

View file

@ -495,6 +495,13 @@ func (site *Site) Publish(key string, val any) {
site.publish(key, val)
}
// clearPlanLocks clears locked plan goals for all loadpoints
func (site *Site) clearPlanLocks() {
for _, lp := range site.Loadpoints() {
lp.ClearPlanLock()
}
}
func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []measurement {
mm := make([]measurement, len(meters))
@ -1049,6 +1056,7 @@ func (site *Site) prepare() {
site.publishVehicles()
site.publishTariffs(0, 0)
vehicle.Publish = site.publishVehicles
vehicle.ClearPlanLocks = site.clearPlanLocks
}
// Prepare attaches communication channels to site and loadpoints

View file

@ -16,6 +16,9 @@ var _ API = (*adapter)(nil)
// Publish publishes vehicle updates at site level
var Publish func()
// ClearPlanLocks clears locked plan goals across all loadpoints
var ClearPlanLocks func()
type adapter struct {
log *util.Logger
name string
@ -32,6 +35,12 @@ func (v *adapter) publish() {
}
}
func (v *adapter) clearPlanLocks() {
if ClearPlanLocks != nil {
ClearPlanLocks()
}
}
func (v *adapter) Instance() api.Vehicle {
return v.Vehicle
}
@ -100,6 +109,9 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
settings.SetTime(v.key()+keys.PlanTime, ts)
settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
// note: could be optimized by only clearing plan lock of the relevant loadpoint
v.clearPlanLocks()
v.publish()
return nil
@ -125,6 +137,10 @@ func (v *adapter) SetRepeatingPlans(plans []api.RepeatingPlan) error {
}
v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans)
// note: could be optimized by only clearing plan lock of the relevant loadpoint
v.clearPlanLocks()
v.publish()
return nil