Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-deadline-reserve-plan-limits.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
13 changes: 10 additions & 3 deletions go/internal/cars/reserve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
35 changes: 30 additions & 5 deletions go/internal/control/ev_deadline_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
}
}
17 changes: 16 additions & 1 deletion go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
10 changes: 5 additions & 5 deletions go/internal/loadpoint/deadline_need.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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)
Expand Down
91 changes: 85 additions & 6 deletions go/internal/loadpoint/deadline_need_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
16 changes: 16 additions & 0 deletions go/internal/loadpoint/planning_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
17 changes: 14 additions & 3 deletions go/internal/mpc/deadline_directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ 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}
}
for _, st := range states {
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
Expand All @@ -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{}
Expand Down
Loading
Loading