diff --git a/.changeset/ev-deadline-reserve-plan-limits.md b/.changeset/ev-deadline-reserve-plan-limits.md new file mode 100644 index 000000000..df59e9c1d --- /dev/null +++ b/.changeset/ev-deadline-reserve-plan-limits.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A car whose own charger takes less than the charger's three-phase levels now charges toward its goal when no plan is available. On a step table with both one-phase and three-phase levels, the reserve FTW falls back to without a plan gave such a car a one-phase level, which no three-phase order has, and the charger was ordered 0 W. An example is a 3.7 kW plug-in hybrid on a charger whose table runs 1.4-3.7 kW on one phase and 4.1-11 kW on three. The reserve now holds to the same limits as the plan: such a car is ordered the lowest three-phase level, and once FTW sees it draw on one phase, three times the level it takes. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index fe3ad6fc9..ad1a18932 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1716,6 +1716,11 @@ func main() { } return states } + // The deadline reserve holds to the plan's limits. The controller + // reads them under its own locks; it is assigned further down. + mpcSvc.PlanningLimits = func(st loadpoint.State) (minW, maxW float64, steps []float64) { + return lpController.PlanningLimits(st) + } mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec { specs := make([]*mpc.LoadpointSpec, 0) for _, st := range lpMgr.States() { diff --git a/go/internal/cars/reserve_test.go b/go/internal/cars/reserve_test.go index 44e697860..634b070de 100644 --- a/go/internal/cars/reserve_test.go +++ b/go/internal/cars/reserve_test.go @@ -20,6 +20,13 @@ func fallback(t *testing.T, h *harness) (ok bool, maxW float64) { return d.LoadpointFallback["easee"], d.LoadpointMaxPowerW["easee"] } +// reserveNeed is the reserve's need for a session on the charger's own +// table, as fallback's service holds it. +func reserveNeed(st loadpoint.State) (energyWh, powerW float64, ok bool) { + minW, maxW, steps := loadpoint.LimitPower(st, st.AllowedStepsW) + return loadpoint.DeadlineNeed(st, minW, maxW, steps) +} + // A failed replan keeps the session car's goal charging, held to that car's // own charger and level, the moment the box knows the car; never a guess for // a car it does not know. @@ -91,7 +98,7 @@ func TestAFailedReplanKeepsTheSessionCarsGoal(t *testing.T) { t.Fatal(err) } st := h.state("easee") - need, peak, ok := loadpoint.DeadlineNeed(st) + need, peak, ok := reserveNeed(st) want := (0.6 - 0.3) * 40000 / loadpoint.DefaultChargeEfficiency if !ok || st.VehicleCapacityWh != 40000 || math.Abs(st.CurrentSoC-.3) > 1e-9 || math.Abs(need-want) > 1 || peak > 7400 { t.Fatalf("the Volvo's need %v (want %v) at %v W, capacity %v, level %v", need, want, peak, st.VehicleCapacityWh, st.CurrentSoC) @@ -117,7 +124,7 @@ func TestAFailedReplanKeepsTheSessionCarsGoal(t *testing.T) { if ok, _ := fallback(t, h); !ok { t.Fatal("a visitor's goal got no reserve") } - if need, _, _ := loadpoint.DeadlineNeed(st); math.Abs(need-0.5*76000/loadpoint.DefaultChargeEfficiency) > 1 { + if need, _, _ := reserveNeed(st); math.Abs(need-0.5*76000/loadpoint.DefaultChargeEfficiency) > 1 { t.Fatalf("a visitor's need %v, want it from empty", need) } h.tick(time.Second) @@ -145,7 +152,7 @@ func TestTheReserveStopsAtTheCarsChargeLimit(t *testing.T) { h.obs["tesla-ha"] = telemetry.VehicleObservation{Driver: "tesla-ha", SoC: .5, ChargeLimit: .7, At: h.now, ReportedAt: h.now} h.tick(time.Second) st := h.state("easee") - need, _, ok := loadpoint.DeadlineNeed(st) + need, _, ok := reserveNeed(st) if want := (0.7 - 0.5) * 76000 / loadpoint.DefaultChargeEfficiency; !ok || st.CarChargeLimit != .7 || math.Abs(need-want) > 50 { t.Fatalf("need %v (want about %v), limit %v", need, want, st.CarChargeLimit) } diff --git a/go/internal/control/ev_deadline_runtime_test.go b/go/internal/control/ev_deadline_runtime_test.go index 6919b6f49..1fef8a8fa 100644 --- a/go/internal/control/ev_deadline_runtime_test.go +++ b/go/internal/control/ev_deadline_runtime_test.go @@ -206,16 +206,40 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) { // Without a plan, a car whose own charger takes less than a stepless // charger's minimum (a 3.7 kW plug-in hybrid on an 11 or 22 kW charger) is // ordered that minimum, as the plan orders it: never 0 W because the car's -// own maximum is lower. +// own maximum is lower. On a table with one-phase and three-phase levels it +// is ordered the lowest three-phase level, not a one-phase level the +// three-phase order cannot run (review of #1807). func TestJoinedNoPlanGoalSmallCarGetsChargerMinimum(t *testing.T) { ctx := context.Background() - for _, maxW := range []float64{11040, 22080} { + mixed := []float64{0} + for _, phases := range []float64{1, 3} { + for a := 6.0; a <= 16; a++ { + mixed = append(mixed, a*230*phases) + } + } + for _, ch := range []struct { + name string + mode string + minW, maxW float64 + steps []float64 + carMaxW float64 + }{ + {"11 kW", "", 4140, 11040, nil, 3680}, + {"22 kW", "", 4140, 22080, nil, 3680}, + {"mixed table", "", 1380, 11040, mixed, 3680}, + {"mixed table, auto", "auto", 1380, 11040, mixed, 3680}, + {"mixed table, 3p", "3p", 1380, 11040, mixed, 3680}, + // The charger's minimum above the car's maximum, and that above + // the lowest one-phase level: the car's own limits leave no level. + {"mixed table, minimum 2,000 W, a 1.5 kW car", "", 2000, 11040, mixed, 1500}, + } { now := time.Now() - cfg := loadpoint.Config{ID: "garage", DriverName: "charger", VehicleCapacityWh: 15000, PluginSoC: .3, MinChargeW: 4140, MaxChargeW: maxW} + cfg := loadpoint.Config{ID: "garage", DriverName: "charger", VehicleCapacityWh: 15000, PluginSoC: .3, PhaseMode: ch.mode, + MinChargeW: ch.minW, MaxChargeW: ch.maxW, AllowedStepsW: ch.steps} mgr := loadpoint.NewManager() mgr.Load([]loadpoint.Config{cfg}) mgr.Observe(cfg.ID, true, 0, 0, true) - if !mgr.SetCarBinding(cfg.ID, loadpoint.CarBinding{ID: "phev", MaxW: 3680}) || + if !mgr.SetCarBinding(cfg.ID, loadpoint.CarBinding{ID: "phev", MaxW: ch.carMaxW}) || !mgr.SetCurrentSoC(cfg.ID, .3) || !mgr.SetTarget(cfg.ID, .8, now.Add(3*time.Hour)) { t.Fatal("goal fixture rejected") } @@ -256,11 +280,12 @@ func TestJoinedNoPlanGoalSmallCarGetsChargerMinimum(t *testing.T) { return nil }) controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + svc.PlanningLimits = controller.PlanningLimits // as main.go wires it controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), controller.FuseConfigs(), 17250) }) controller.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(st) }) controller.TickWithDispatch(ctx, now, true) if len(commands) == 0 || commands[len(commands)-1] != 4140 { - t.Fatalf("%.0f W charger: orders %v, want the charger's minimum 4140 W", maxW, commands) + t.Errorf("%s: orders %v, want 4140 W", ch.name, commands) } } } diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index a907b6ca2..29263817c 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -302,6 +302,20 @@ func probeCases() []fieldCase { c.carPhases = 1 out = append(out, c) } + for _, fuse := range []float64{16, 25} { + for _, ms := range []struct{ mode, steps string }{{"", "mixed"}, {"auto", "mixed"}, {"3p", "mixed"}, {"", "3p"}, {"auto", "3p"}} { + // The same car on a table with three-phase levels: the reserve + // orders one of them, as the plan does. + out = append(out, fieldCase{report: "probe", fuseA: fuse, mode: ms.mode, steps: ms.steps, timing: timingEasee, carPhases: 1, use: useNoPlan, + house: houseBalanced, houseName: "balanced"}) + } + } + for _, steps := range []string{"mixed", "3p"} { + // A 2.3 kW one-phase car, below the charger's three-phase levels + // over three: marked, it is ordered three times its own level. + out = append(out, fieldCase{report: "car2300W", fuseA: 25, steps: steps, timing: timingFast, carPhases: 1, use: useNoPlan, + house: houseBalanced, houseName: "balanced", car: func(car *sitesim.Car) { car.MaxW = 2300 }}) + } c := helpReport(usePlanned, timingEasee) c.houseStep = 30 * time.Minute return append(out, c) @@ -755,7 +769,8 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) s.plan = f.plan.directive case useNoPlan: run, kettle = time.Hour, []time.Duration{20 * time.Minute} - svc := &mpc.Service{LoadpointStates: func() []loadpoint.State { return s.mgr.States() }} + svc := &mpc.Service{LoadpointStates: func() []loadpoint.State { return s.mgr.States() }, + PlanningLimits: func(st loadpoint.State) (float64, float64, []float64) { return s.ctrl.PlanningLimits(st) }} // as main.go wires it s.plan = func(now time.Time) (loadpoint.Directive, bool) { d, ok := svc.SlotDirectiveAt(now) return d.LoadpointDirective(), ok diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 3e9fa1dfd..1987b8b2c 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -2626,7 +2626,8 @@ func (c *Controller) computeCommandForBudget(now time.Time, lpCfg Config, curren } if d.LoadpointFallback[lpCfg.ID] { st, known := c.manager.State(lpCfg.ID) - if _, _, needed := DeadlineNeed(st); !known || !needed { + minW, maxW, steps := c.PlanningLimits(st) + if _, _, needed := DeadlineNeed(st, minW, maxW, steps); !known || !needed { return 0, true, false } } diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index dd1a8f2a6..c061943d0 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -4,10 +4,11 @@ import ( "math" ) -// DeadlineNeed is the remaining AC energy and legal on-power of a saved goal, -// held to the session car's own charger and charge limit as the plan is. It -// does not promise future site capacity or authorize a hardware command. -func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { +// DeadlineNeed is the remaining AC energy of a saved goal, held to the car's +// charge limit, and its legal on-power within minW-maxW and steps: the limits +// the plan gets for the session (PlanningLimits). It does not promise future +// site capacity or authorize a hardware command. +func DeadlineNeed(st State, minW, maxW float64, steps []float64) (energyWh, powerW float64, ok bool) { if !st.PluggedIn || st.ChargingDeclined || st.GoalComplete || st.TargetTime.IsZero() || !finite(st.CurrentSoC) || st.CurrentSoC < 0 || st.CurrentSoC > 1 || !finite(st.TargetSoC) || st.TargetSoC < 0 || st.TargetSoC > 1 || @@ -27,7 +28,6 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { return 0, 0, false } } - minW, maxW, steps := LimitPower(st, st.AllowedStepsW) for _, step := range steps { if step >= minW { powerW = math.Max(powerW, step) diff --git a/go/internal/loadpoint/deadline_need_test.go b/go/internal/loadpoint/deadline_need_test.go index d8340dd66..b6b670a9d 100644 --- a/go/internal/loadpoint/deadline_need_test.go +++ b/go/internal/loadpoint/deadline_need_test.go @@ -29,33 +29,112 @@ func TestLimitPowerKeepsTheChargersLowestStep(t *testing.T) { } } +// deadlineNeed is the reserve's need within the limits the plan gets on a +// 3 × 25 A site, phase mode unset. +func deadlineNeed(st State) (energyWh, powerW float64, ok bool) { + cfg := Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW} + minW, maxW, steps := PlanningLimits(st, cfg, SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + return DeadlineNeed(st, minW, maxW, steps) +} + // The reserve's need and on-power hold to the session car's own charger and // its charge limit, as the plan does. func TestDeadlineNeedHoldsToTheCar(t *testing.T) { st := State{PluggedIn: true, VehicleCapacityWh: 40000, CurrentSoC: .3, TargetSoC: .8, TargetTime: time.Now().Add(3 * time.Hour), MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 7360, 11000}} - if _, w, ok := DeadlineNeed(st); !ok || w != 11000 { + if _, w, ok := deadlineNeed(st); !ok || w != 11000 { t.Fatalf("no car limit: %v %v", w, ok) } st.CarMaxW = 7400 - if _, w, ok := DeadlineNeed(st); !ok || w != 7360 { + if _, w, ok := deadlineNeed(st); !ok || w != 7360 { t.Fatalf("a 7.4 kW car: %v %v", w, ok) } st.AllowedStepsW = nil - if _, w, ok := DeadlineNeed(st); !ok || w != 7400 { + if _, w, ok := deadlineNeed(st); !ok || w != 7400 { t.Fatalf("a 7.4 kW car on a stepless charger: %v %v", w, ok) } st.CarMaxW = 3680 - if _, w, ok := DeadlineNeed(st); !ok || w != 4140 { + if _, w, ok := deadlineNeed(st); !ok || w != 4140 { t.Fatalf("a 3.7 kW car on a stepless charger: %v %v, want the charger's minimum", w, ok) } st.CarMaxW = 7400 st.CarChargeLimit = .6 - if need, _, ok := DeadlineNeed(st); !ok || math.Abs(need-(.6-.3)*40000/DefaultChargeEfficiency) > 1e-6 { + if need, _, ok := deadlineNeed(st); !ok || math.Abs(need-(.6-.3)*40000/DefaultChargeEfficiency) > 1e-6 { t.Fatalf("a 60 %% limit: %v %v", need, ok) } st.CurrentSoC = .6 - if _, _, ok := DeadlineNeed(st); ok { + if _, _, ok := deadlineNeed(st); ok { t.Fatal("a car at its own limit still needs energy") } } + +// Review of #1807: on a table with one-phase and three-phase levels, planned +// charging runs on three phases. A car whose own maximum is below the +// lowest three-phase level got a one-phase level as its on-power, which no +// three-phase order has, and was ordered 0 W. It gets that lowest level, as +// the plan gives it. A car marked one-phase gets the third of a three-phase +// level it takes, up to its own maximum, on that table and on one with +// three-phase levels only. +func TestDeadlineNeedRunsOnThePlansPhases(t *testing.T) { + var mixed, threePhase []float64 + for a := 6.0; a <= 16; a++ { + mixed, threePhase = append(mixed, a*230), append(threePhase, a*690) + } + mixed = append(append([]float64{0}, mixed...), threePhase...) + threePhase = append([]float64{0}, threePhase...) + for _, c := range []struct { + name string + minW float64 + steps []float64 + carMaxW float64 + carPhases int + want float64 + }{ + {"mixed table, a 3.7 kW car", 1380, mixed, 3680, 0, 4140}, + {"mixed table, a 3.7 kW car marked one-phase", 1380, mixed, 3680, 1, 3680}, + {"mixed table, a 2.3 kW car marked one-phase", 1380, mixed, 2300, 1, 2300}, + {"three-phase table, a 3.7 kW car", 4140, threePhase, 3680, 0, 4140}, + {"three-phase table, a 2.3 kW car marked one-phase", 4140, threePhase, 2300, 1, 2300}, + } { + st := State{PluggedIn: true, VehicleCapacityWh: 40000, CurrentSoC: .3, TargetSoC: .8, TargetTime: time.Now().Add(3 * time.Hour), + MinChargeW: c.minW, MaxChargeW: 11040, AllowedStepsW: c.steps, CarMaxW: c.carMaxW, CarPhases: c.carPhases} + if _, w, ok := deadlineNeed(st); !ok || w != c.want { + t.Errorf("%s: on-power %v ok=%v, want %v", c.name, w, ok, c.want) + } + } +} + +// The controller's limits read the loadpoint's phase mode and the site fuse +// as they stand. A three-phase site keeps a mixed table's three-phase levels; +// a 1p pin or a one-phase site keeps its one-phase levels. +func TestControllerPlanningLimitsReadPhaseModeAndSite(t *testing.T) { + mixed := []float64{0} + for _, phases := range []float64{1, 3} { + for a := 6.0; a <= 16; a++ { + mixed = append(mixed, a*230*phases) + } + } + st := State{ID: "garage", MinChargeW: 1380, MaxChargeW: 11040, AllowedStepsW: mixed, CarMaxW: 3680} + for _, c := range []struct { + name string + mode string + phases int + want float64 + }{ + {"three-phase site", "", 3, 4140}, + {"three-phase site, auto", "auto", 3, 4140}, + {"pinned 1p", "1p", 3, 3680}, + {"one-phase site", "", 1, 3680}, + } { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee", PhaseMode: c.mode, MinChargeW: 1380, MaxChargeW: 11040, AllowedStepsW: mixed}}) + ctrl := NewController(m, nil, nil, nil) + ctrl.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: c.phases}) + if _, maxW, _ := ctrl.PlanningLimits(st); maxW != c.want { + t.Errorf("%s: top %v W, want %v W", c.name, maxW, c.want) + } + } + if _, maxW, _ := (*Controller)(nil).PlanningLimits(st); maxW != 3680 { + t.Errorf("no controller: top %v W, want the charger's own table", maxW) + } +} diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index 1e2614a2c..538d70bc3 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -73,6 +73,22 @@ func PlanningLimits(st State, cfg Config, site SiteFuse) (minW, maxW float64, st return LimitPower(st, planning) } +// PlanningLimits is PlanningLimits on this loadpoint's charger and the site +// fuse, as both stand now. The deadline reserve holds to them, as the plan +// does. Without a controller the reserve holds to the charger's own table. +func (c *Controller) PlanningLimits(st State) (minW, maxW float64, steps []float64) { + if c == nil { + return LimitPower(st, st.AllowedStepsW) + } + cfg := Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW} + for _, lp := range c.manager.Configs() { + if lp.ID == st.ID { + cfg.PhaseMode = lp.PhaseMode + } + } + return PlanningLimits(st, cfg, c.siteFuse()) +} + // ThreePhaseCapable reports whether a charger can run three phases at 6 A // each; see threePhaseCapable. func ThreePhaseCapable(cfg Config, site SiteFuse) bool { return threePhaseCapable(cfg, site) } diff --git a/go/internal/mpc/deadline_directive.go b/go/internal/mpc/deadline_directive.go index 6d7005a4a..e943a10b4 100644 --- a/go/internal/mpc/deadline_directive.go +++ b/go/internal/mpc/deadline_directive.go @@ -14,6 +14,12 @@ func (s *Service) withDeadlineReserve(now time.Time, d SlotDirective, accepted b return d, accepted } states := s.LoadpointStates() // No control lock: the caller may hold it. + limits := s.PlanningLimits + if limits == nil { + limits = func(st loadpoint.State) (float64, float64, []float64) { + return loadpoint.LimitPower(st, st.AllowedStepsW) + } + } if !accepted { d = SlotDirective{SlotStart: now, SlotEnd: now.Add(time.Minute), EVOnly: true} } @@ -21,7 +27,8 @@ func (s *Service) withDeadlineReserve(now time.Time, d SlotDirective, accepted b if _, hasBudget := d.LoadpointEnergyWh[st.ID]; hasBudget && !st.ManualActive { continue // An accepted zero budget is a price decision, too. } - need, peak, ok := loadpoint.DeadlineNeed(st) + minW, maxW, steps := limits(st) + need, peak, ok := loadpoint.DeadlineNeed(st, minW, maxW, steps) if st.ManualActive && st.PluggedIn { // Manual intent has already passed the charger's own clamps. // Reserve its ceiling, including an explicit Stop, without @@ -38,8 +45,12 @@ func (s *Service) withDeadlineReserve(now time.Time, d SlotDirective, accepted b if !st.ManualActive { // Near the goal use the smallest legal offer that can deliver // this minute's need, rather than reserving the charger's full - // rating for a final few Wh. - peak = loadpoint.SnapChargeW(need/d.SlotEnd.Sub(now).Hours(), st.MinChargeW, peak, st.AllowedStepsW) + // rating for a final few Wh. A charger without a table takes + // any power in its range. + if len(st.AllowedStepsW) == 0 { + steps = nil + } + peak = loadpoint.SnapChargeW(need/d.SlotEnd.Sub(now).Hours(), minW, peak, steps) } if d.LoadpointFallback == nil { d.LoadpointFallback = map[string]bool{} diff --git a/go/internal/mpc/deadline_directive_test.go b/go/internal/mpc/deadline_directive_test.go index 11da0195d..924b49204 100644 --- a/go/internal/mpc/deadline_directive_test.go +++ b/go/internal/mpc/deadline_directive_test.go @@ -131,3 +131,43 @@ func TestGoalReserveGivesASmallCarTheChargersMinimum(t *testing.T) { } } } + +// Review of #1807: on a table with one-phase and three-phase levels the +// reserve holds to the limits the plan gets, as planned charging runs on +// three phases. A 3.7 kW car is reserved the lowest three-phase level, at +// full need and near its goal; marked one-phase, it is reserved the third of +// a three-phase level it takes. A one-phase level was ordered 0 W. +func TestGoalReserveHoldsToThePlansLimits(t *testing.T) { + now := time.Now() + steps := []float64{0} + for _, phases := range []float64{1, 3} { + for a := 6.0; a <= 16; a++ { + steps = append(steps, a*230*phases) + } + } + site := loadpoint.SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3} + for _, c := range []struct { + name string + carPhases int + soc float64 + want float64 + }{ + {"a 3.7 kW car", 0, .3, 4140}, + {"a 3.7 kW car near its goal", 0, .7999, 4140}, + {"marked one-phase", 1, .3, 3680}, + {"marked one-phase near its goal", 1, .7999, 1380}, + } { + st := deadlineGoalState(now, "a") + st.MinChargeW, st.MaxChargeW, st.AllowedStepsW, st.CarMaxW, st.CarPhases, st.CurrentSoC = 1380, 11040, steps, 3680, c.carPhases, c.soc + svc := &Service{ + LoadpointStates: func() []loadpoint.State { return []loadpoint.State{st} }, + PlanningLimits: func(st loadpoint.State) (float64, float64, []float64) { + return loadpoint.PlanningLimits(st, loadpoint.Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW}, site) + }, + } + d, ok := svc.SlotDirectiveAt(now) + if !ok || !d.LoadpointFallback["a"] || d.LoadpointMaxPowerW["a"] != c.want || d.LoadpointEnergyWh["a"] <= 0 { + t.Errorf("%s: reserve %v W, %v Wh, ok=%v; want %v W", c.name, d.LoadpointMaxPowerW["a"], d.LoadpointEnergyWh["a"], ok, c.want) + } + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 70239123b..524cb1eb6 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -165,6 +165,10 @@ type Service struct { // LoadpointStates supplies current saved goals without taking the control // lock. It is independent of forecasts, prices and battery qualification. LoadpointStates func() []loadpoint.State + // PlanningLimits is the power range and the steps the plan gets for a + // session (loadpoint.PlanningLimits); the deadline reserve holds to them + // too. Nil holds the reserve to the charger's own table (LimitPower). + PlanningLimits func(loadpoint.State) (minW, maxW float64, steps []float64) // SaveDiag is called synchronously after every successful replan that // remains the newest request, with the same Diagnostic the