From c5fbdd47aa29f1396466169337b48090a8e9b352 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 19:14:27 +0100 Subject: [PATCH] fix(loadpoint): the deadline reserve holds to the plan's limits, not the charger's raw table With no plan, withDeadlineReserve took its on-power from DeadlineNeed, which held it to LimitPower(st, st.AllowedStepsW): the charger's whole table. On a table with one-phase and three-phase levels, a car whose own maximum is below the lowest three-phase level (a 3,680 W plug-in hybrid) got a one-phase level, 3,680 W. Planned charging runs on three phases, so the controller snapped with the three-phase minimum, 4,140 W, and sent 0 W. The car is marked one-phase only once it draws, so it never was. The plan path gets PlanningLimits and orders 4,140 W. DeadlineNeed now takes the limits. Controller.PlanningLimits is PlanningLimits on the loadpoint's phase mode and the site fuse, read under the controller's and the manager's own locks. The controller's reserve check uses it, and main.go wires it into the new mpc.Service.PlanningLimits; nil keeps the charger's table. A marked one-phase car is reserved the third of a three-phase level it takes, so a 2.3 kW car on a three-phase table is ordered 6,900 W, not the charger's 11,040 W. A charger without a table still takes any power in its range. Tests fail on master 286ac141: DeadlineNeed on mixed and three-phase tables, the reserve through SlotDirectiveAt, and the controller's order on mixed tables (unset, auto, 3p). Of twelve new noplan field-matrix rows, the seven on mixed tables go idle on master (order 0 W); the five on three-phase tables pass on both. The known list stays empty. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-deadline-reserve-plan-limits.md | 5 + go/cmd/ftw/main.go | 5 + go/internal/cars/reserve_test.go | 13 ++- .../control/ev_deadline_runtime_test.go | 35 ++++++- go/internal/control/sitesim_field_test.go | 17 +++- go/internal/loadpoint/controller.go | 3 +- go/internal/loadpoint/deadline_need.go | 10 +- go/internal/loadpoint/deadline_need_test.go | 91 +++++++++++++++++-- go/internal/loadpoint/planning_steps.go | 16 ++++ go/internal/mpc/deadline_directive.go | 17 +++- go/internal/mpc/deadline_directive_test.go | 40 ++++++++ go/internal/mpc/service.go | 4 + 12 files changed, 232 insertions(+), 24 deletions(-) create mode 100644 .changeset/ev-deadline-reserve-plan-limits.md 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 3c1f5de0a..7b2087354 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1702,6 +1702,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 d8fec988b..4991c7e59 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -160,6 +160,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