Planner: add precondition phase before departure (#20431)

This commit is contained in:
andig 2025-04-25 14:10:09 +02:00 • committed by GitHub
parent c4be8e918d
commit 832012ef15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 746 additions and 222 deletions

View file

@ -70,6 +70,7 @@ const (
PlanTime = "planTime" // charge plan finish time goal
PlanEnergy = "planEnergy" // charge plan energy goal
PlanSoc = "planSoc" // charge plan soc goal
PlanPrecondition = "planPrecondition" // charge plan precondition duration
PlanActive = "planActive" // charge plan has determined current slot to be an active slot
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)

View file

@ -140,11 +140,12 @@ type Loadpoint struct {
socEstimator *soc.Estimator
// charge planning
planner *planner.Planner
planTime time.Time // time goal
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
planner *planner.Planner
planTime time.Time // time goal
planPrecondition time.Duration // precondition duration
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
// cached state
status api.ChargeStatus // Charger status
@ -342,8 +343,9 @@ func (lp *Loadpoint) restoreSettings() {
t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy)
d, _ := lp.settings.Int(keys.PlanPrecondition)
if err1 == nil && err2 == nil {
lp.setPlanEnergy(t, v)
lp.setPlanEnergy(t, time.Duration(d)*time.Second, v)
}
}
@ -657,6 +659,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
// restored settings
lp.publish(keys.PlanTime, lp.planTime)
lp.publish(keys.PlanEnergy, lp.planEnergy)
lp.publish(keys.PlanPrecondition, lp.planPrecondition)
lp.publish(keys.LimitSoc, lp.limitSoc)
lp.publish(keys.LimitEnergy, lp.limitEnergy)
@ -953,7 +956,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
if !lp.socBasedPlanning() {
return false
}
_, _, id := lp.NextVehiclePlan()
_, _, _, id := lp.NextVehiclePlan()
return id > 1
}

View file

@ -114,17 +114,19 @@ type API interface {
//
// GetPlanEnergy returns the charge plan energy
GetPlanEnergy() (time.Time, float64)
GetPlanEnergy() (time.Time, time.Duration, float64)
// SetPlanEnergy sets the charge plan energy
SetPlanEnergy(time.Time, float64) error
// GetPlanGoal returns the plan goal and if the goal is soc based
SetPlanEnergy(time.Time, time.Duration, float64) error
// GetPlanGoal returns the plan goal, precondition duration and if the goal is soc based
GetPlanGoal() (float64, bool)
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
GetPlanRequiredDuration(goal, maxPower float64) time.Duration
// GetPlanPreCondDuration returns the precondition duration
GetPlanPreCondDuration() time.Duration
// SocBasedPlanning determines if the planner is soc based
SocBasedPlanning() bool
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates
// GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig

View file

@ -26,6 +26,7 @@ type DynamicConfig struct {
SmartCostLimit *float64 `json:"smartCostLimit"`
PlanEnergy float64 `json:"planEnergy"`
PlanTime time.Time `json:"planTime"`
PlanPrecondition int64 `json:"planPrecondition"`
LimitEnergy float64 `json:"limitEnergy"`
LimitSoc int `json:"limitSoc"`
@ -56,7 +57,7 @@ func (payload DynamicConfig) Apply(lp API) error {
lp.SetPriority(payload.Priority)
lp.SetSmartCostLimit(payload.SmartCostLimit)
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy)
lp.SetLimitEnergy(payload.LimitEnergy)
lp.SetLimitSoc(payload.LimitSoc)

View file

@ -420,26 +420,27 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call {
}
// GetPlan mocks base method.
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration)
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition)
ret0, _ := ret[0].(api.Rates)
return ret0
}
// GetPlan indicates an expected call of GetPlan.
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration any) *gomock.Call {
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition)
}
// GetPlanEnergy mocks base method.
func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanEnergy")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(float64)
return ret0, ret1
ret1, _ := ret[1].(time.Duration)
ret2, _ := ret[2].(float64)
return ret0, ret1, ret2
}
// GetPlanEnergy indicates an expected call of GetPlanEnergy.
@ -463,6 +464,20 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal))
}
// GetPlanPreCondDuration mocks base method.
func (m *MockAPI) GetPlanPreCondDuration() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanPreCondDuration")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetPlanPreCondDuration indicates an expected call of GetPlanPreCondDuration.
func (mr *MockAPIMockRecorder) GetPlanPreCondDuration() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanPreCondDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanPreCondDuration))
}
// GetPlanRequiredDuration mocks base method.
func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
m.ctrl.T.Helper()
@ -844,17 +859,17 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call {
}
// SetPlanEnergy mocks base method.
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error {
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1)
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanEnergy indicates an expected call of SetPlanEnergy.
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call {
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2)
}
// SetPriority mocks base method.

View file

@ -327,19 +327,19 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
}
// GetPlanEnergy returns plan target energy
func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) {
func (lp *Loadpoint) GetPlanEnergy() (time.Time, time.Duration, float64) {
lp.RLock()
defer lp.RUnlock()
return lp.getPlanEnergy()
}
// getPlanEnergy returns plan target energy
func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) {
return lp.planTime, lp.planEnergy
func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) {
return lp.planTime, lp.planPrecondition, lp.planEnergy
}
// setPlanEnergy sets plan target energy (no mutex)
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) {
lp.planEnergy = energy
lp.publish(keys.PlanEnergy, energy)
lp.settings.SetFloat(keys.PlanEnergy, energy)
@ -347,11 +347,14 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
// remove plan
if energy == 0 {
finishAt = time.Time{}
precondition = 0
}
lp.planTime = finishAt
lp.publish(keys.PlanTime, finishAt)
lp.publish(keys.PlanPrecondition, precondition)
lp.settings.SetTime(keys.PlanTime, finishAt)
lp.settings.SetInt(keys.PlanPrecondition, int64(precondition.Seconds()))
if finishAt.IsZero() {
lp.setPlanActive(false)
@ -359,7 +362,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
}
// SetPlanEnergy sets plan target energy
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error {
lp.Lock()
defer lp.Unlock()
@ -370,8 +373,8 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local())
// apply immediately
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, energy)
if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, precondition, energy)
lp.requestUpdate()
}

View file

@ -32,10 +32,11 @@ func (lp *Loadpoint) EffectivePriority() int {
}
type plan struct {
Id int
Start time.Time // last possible start time
End time.Time // user-selected finish time
Soc int
Id int
Start time.Time // last possible start time
End time.Time // user-selected finish time
Precondition time.Duration
Soc int
}
func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
@ -59,20 +60,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
}
// NextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) {
lp.RLock()
defer lp.RUnlock()
return lp.nextVehiclePlan()
}
// nextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
if v := lp.GetVehicle(); v != nil {
var plans []plan
// static plan
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime})
}
// repeating plans
@ -81,33 +82,34 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
continue
}
time, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
planTime, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
if err != nil {
lp.log.DEBUG.Printf("invalid repeating plan: weekdays=%v, time=%s, tz=%s, error=%v", rp.Weekdays, rp.Time, rp.Tz, err)
continue
}
plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: time})
precondition := time.Duration(rp.Precondition) * time.Second
plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime})
}
// calculate earliest required plan start
if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil {
return plan.End, plan.Soc, plan.Id
return plan.End, plan.Precondition, plan.Soc, plan.Id
}
}
return time.Time{}, 0, 0
return time.Time{}, 0, 0, 0
}
// EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int {
_, soc, _ := lp.NextVehiclePlan()
_, _, soc, _ := lp.NextVehiclePlan()
return soc
}
// EffectivePlanId returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int {
if lp.socBasedPlanning() {
_, _, id := lp.NextVehiclePlan()
_, _, _, id := lp.NextVehiclePlan()
return id
}
if lp.planEnergy > 0 {
@ -120,11 +122,11 @@ func (lp *Loadpoint) EffectivePlanId() int {
// EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time {
if lp.socBasedPlanning() {
ts, _, _ := lp.NextVehiclePlan()
ts, _, _, _ := lp.NextVehiclePlan()
return ts
}
ts, _ := lp.GetPlanEnergy()
ts, _, _ := lp.GetPlanEnergy()
return ts
}

View file

@ -33,9 +33,9 @@ func (lp *Loadpoint) finishPlan() {
if lp.repeatingPlanning() {
return // noting to do
} else if !lp.socBasedPlanning() {
lp.setPlanEnergy(time.Time{}, 0)
lp.setPlanEnergy(time.Time{}, 0, 0)
} else if v := lp.GetVehicle(); v != nil {
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0)
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0)
}
}
@ -70,21 +70,36 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
defer lp.RUnlock()
if lp.socBasedPlanning() {
_, soc, _ := lp.nextVehiclePlan()
_, _, soc, _ := lp.nextVehiclePlan()
return float64(soc), true
}
_, limit := lp.getPlanEnergy()
_, _, limit := lp.getPlanEnergy()
return limit, false
}
// GetPlanPreCondDuration returns the plan precondition duration
func (lp *Loadpoint) GetPlanPreCondDuration() time.Duration {
lp.RLock()
defer lp.RUnlock()
if lp.socBasedPlanning() {
_, precondition, _, _ := lp.nextVehiclePlan()
return precondition
}
_, precondition, _ := lp.getPlanEnergy()
return precondition
}
// GetPlan creates a charging plan for given time and duration
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
// The plan is sorted by time
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
if lp.planner == nil || targetTime.IsZero() {
return nil
}
return lp.planner.Plan(requiredDuration, targetTime)
return lp.planner.Plan(requiredDuration, precondition, targetTime)
}
// plannerActive checks if the charging plan has a currently active slot
@ -132,7 +147,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
return false
}
plan := lp.GetPlan(planTime, requiredDuration)
plan := lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration())
if plan == nil {
return false
}

View file

@ -136,7 +136,7 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
return res
}
func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api.Rates {
func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates {
if t == nil || requiredDuration <= 0 {
return nil
}
@ -178,6 +178,9 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
// sort rates by price and time
slices.SortStableFunc(rates, sortByCost)
// for late start ensure that the last slot is the cheapest
rates, adjusted := splitPreconditionSlots(rates, precondition, targetTime)
// reduce planning horizon to available rates
if targetTime.After(last) {
// there is enough time for charging after end of current rates
@ -194,10 +197,62 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
requiredDuration -= durationAfterRates
}
// sort rates by price and time
slices.SortStableFunc(rates, sortByCost)
plan := t.plan(rates, requiredDuration, targetTime)
// correct plan slots to show original, non-adjusted prices
for i, r := range plan {
if rr, err := adjusted.At(r.Start); err == nil {
plan[i].Value = rr.Value
}
}
// sort plan by time
plan.Sort()
return plan
}
func splitPreconditionSlots(rates api.Rates, precondition time.Duration, targetTime time.Time) (api.Rates, api.Rates) {
var res, adjusted api.Rates
for _, r := range slices.Clone(rates) {
preCondStart := targetTime.Add(-precondition)
if !r.End.After(preCondStart) {
res = append(res, r)
continue
}
// split slot
if !r.Start.After(preCondStart) {
// keep the first part of the slot
res = append(res, api.Rate{
Start: r.Start,
End: preCondStart,
Value: r.Value,
})
// adjust the second part of the slot
r = api.Rate{
Start: preCondStart,
End: r.End,
Value: r.Value,
}
}
// set the value to 0 to include slot in the plan
res = append(res, api.Rate{
Start: r.Start,
End: r.End,
Value: 0,
})
// keep a copy of the adjusted slot
adjusted = append(adjusted, r)
}
return res, adjusted
}

View file

@ -133,7 +133,7 @@ func TestNilTariff(t *testing.T) {
clock: clock,
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
@ -155,7 +155,7 @@ func TestRatesError(t *testing.T) {
tariff: trf,
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
@ -184,10 +184,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
},
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute))
assert.Equal(t, simplePlan, plan, "expected simple plan")
}
@ -208,12 +208,12 @@ func TestFlatTariffLongSlots(t *testing.T) {
// that slots are not longer than 1 hour and with that context this is not a problem
// expect 00:00-01:00 UTC
plan := p.Plan(time.Hour, clock.Now().Add(2*time.Hour))
plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour))
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan))
// expect 00:00-01:00 UTC
plan = p.Plan(time.Hour, clock.Now().Add(time.Hour))
plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour))
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
}
@ -230,10 +230,10 @@ func TestTargetAfterKnownPrices(t *testing.T) {
tariff: trf,
}
plan := p.Plan(40*time.Minute, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ")
plan = p.Plan(2*time.Hour, clock.Now().Add(2*time.Hour))
plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour))
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
}
@ -257,13 +257,64 @@ func TestChargeAfterTargetTime(t *testing.T) {
},
}
plan := p.Plan(time.Hour, clock.Now())
plan := p.Plan(time.Hour, 0, clock.Now())
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour))
assert.Equal(t, simplePlan, plan, "expected simple plan")
}
func TestPrecondition(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 1, 2, 3}, clock.Now(), time.Hour), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * time.Hour),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected last slot")
plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(1 * time.Hour),
Value: 0,
},
{
Start: clock.Now().Add(3 * time.Hour),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected two slots")
plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(30 * time.Minute),
End: clock.Now().Add(time.Hour),
Value: 0,
},
{
Start: clock.Now().Add(210 * time.Minute),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected short early and split late slot")
}
func TestContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock()
@ -272,7 +323,7 @@ func TestContinuousPlanNoTariff(t *testing.T) {
clock: clock,
}
plan := p.Plan(time.Hour, clock.Now())
plan := p.Plan(time.Hour, 0, clock.Now())
// single-slot plan
assert.Len(t, plan, 1)
@ -293,7 +344,7 @@ func TestContinuousPlan(t *testing.T) {
tariff: trf,
}
plan := p.Plan(150*time.Minute, clock.Now())
plan := p.Plan(150*time.Minute, 0, clock.Now())
// 3-slot plan
assert.Len(t, plan, 3)
@ -312,7 +363,7 @@ func TestContinuousPlanOutsideRates(t *testing.T) {
tariff: trf,
}
plan := p.Plan(30*time.Minute, clock.Now())
plan := p.Plan(30*time.Minute, 0, clock.Now())
// 3-slot plan
assert.Len(t, plan, 1)

View file

@ -99,14 +99,16 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
}
fc := struct {
Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
}{
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
Planner: tariff.Forecast(site.GetTariff(api.TariffUsagePlanner)),
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
}
// calculate adjusted solar forecast

View file

@ -13,8 +13,9 @@ import (
)
type planStruct struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
Soc int `json:"soc"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}
type vehicleStruct struct {
@ -40,8 +41,8 @@ func (site *Site) publishVehicles() {
for _, v := range vv {
var plan *planStruct
if time, soc := v.GetPlanSoc(); !time.IsZero() {
plan = &planStruct{Soc: soc, Time: time}
if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() {
plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time}
}
instance := v.Instance()

View file

@ -71,20 +71,24 @@ func (v *adapter) SetLimitSoc(soc int) {
}
// GetPlanSoc returns the charge plan soc
func (v *adapter) GetPlanSoc() (time.Time, int) {
func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) {
var ts time.Time
if v, err := settings.Time(v.key() + keys.PlanTime); err == nil {
ts = v
}
var precondition time.Duration
if v, err := settings.Int(v.key() + keys.PlanPrecondition); err == nil {
precondition = time.Duration(v) * time.Second
}
var soc int
if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil {
soc = int(v)
}
return ts, soc
return ts, precondition, soc
}
// SetPlanSoc sets the charge plan soc
func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
if !ts.IsZero() && ts.Before(time.Now()) {
return errors.New("timestamp is in the past")
}
@ -94,10 +98,11 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
ts = time.Time{}
v.log.DEBUG.Printf("delete %s plan", v.name)
} else {
v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition)
}
settings.SetTime(v.key()+keys.PlanTime, ts)
settings.SetInt(v.key()+keys.PlanPrecondition, int64(precondition.Seconds()))
settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
v.publish()

View file

@ -39,9 +39,9 @@ type API interface {
SetLimitSoc(soc int)
// GetPlanSoc returns the charge plan soc
GetPlanSoc() (time.Time, int)
GetPlanSoc() (time.Time, time.Duration, int)
// SetPlanSoc sets the charge plan time and soc
SetPlanSoc(time.Time, int) error
SetPlanSoc(time.Time, time.Duration, int) error
// GetRepeatingPlans returns every repeating plan
GetRepeatingPlans() []api.RepeatingPlanStruct

View file

@ -39,12 +39,12 @@ func (v *dummy) SetLimitSoc(soc int) {
}
// GetPlanSoc returns the charge plan soc
func (v *dummy) GetPlanSoc() (time.Time, int) {
return time.Time{}, 0
func (v *dummy) GetPlanSoc() (time.Time, time.Duration, int) {
return time.Time{}, 0, 0
}
// SetPlanSoc sets the charge plan soc
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
return nil
}

View file

@ -70,12 +70,13 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
}
// GetPlanSoc mocks base method.
func (m *MockAPI) GetPlanSoc() (time.Time, int) {
func (m *MockAPI) GetPlanSoc() (time.Time, time.Duration, int) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanSoc")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(int)
return ret0, ret1
ret1, _ := ret[1].(time.Duration)
ret2, _ := ret[2].(int)
return ret0, ret1, ret2
}
// GetPlanSoc indicates an expected call of GetPlanSoc.
@ -151,17 +152,17 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call {
}
// SetPlanSoc mocks base method.
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error {
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 time.Duration, arg2 int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1)
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanSoc indicates an expected call of SetPlanSoc.
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call {
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1, arg2 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1, arg2)
}
// SetRepeatingPlans mocks base method.