From 2671732c3220c8fa17e3febc4c2be3bb97c35be6 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 1 Feb 2026 13:00:51 +0100 Subject: [PATCH] Planner: fix repeating overrun (#26529) --- core/loadpoint.go | 7 ++++-- core/loadpoint/api.go | 2 ++ core/loadpoint/mock.go | 12 ++++++++++ core/loadpoint_api.go | 3 +++ core/loadpoint_effective.go | 38 ++++++++++++++++++++------------ core/loadpoint_effective_test.go | 35 +++++++++++++++++++++++++++++ core/loadpoint_plan.go | 34 ++++++++++++++++++++++++++++ core/site.go | 8 +++++++ core/vehicle/adapter.go | 16 ++++++++++++++ 9 files changed, 139 insertions(+), 16 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6efca15e4..5d6b95207 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 55ba3b84e..53695d534 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 61e93af70..dc81b3683 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 4eb9e8eac..961cd9f83 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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) diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 49d1b7461..71ee49a0c 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -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 } diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index f479e0860..e852a9971 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -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) + }) +} diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index 4faddae61..68f52bdf4 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -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 { diff --git a/core/site.go b/core/site.go index 087d5023c..be34ff188 100644 --- a/core/site.go +++ b/core/site.go @@ -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 diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go index 40fbaf5eb..ca271ae74 100644 --- a/core/vehicle/adapter.go +++ b/core/vehicle/adapter.go @@ -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