diff --git a/.changeset/ev-deadline-car-below-charger-min.md b/.changeset/ev-deadline-car-below-charger-min.md new file mode 100644 index 000000000..0ba82bc20 --- /dev/null +++ b/.changeset/ev-deadline-car-below-charger-min.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A car whose own charger takes less than the charger's minimum now charges toward its goal when no plan is available. Without a plan, FTW falls back to a reserve that runs the goal on its own. On a charger without a step table, that reserve gave such a car 0 W, for example a 3.7 kW plug-in hybrid on an 11 or 22 kW charger whose minimum is 4.1 kW. The reserve now orders the charger's minimum, as a plan does, and the car takes what it can. diff --git a/go/internal/control/ev_deadline_runtime_test.go b/go/internal/control/ev_deadline_runtime_test.go index 1f4f12047..6919b6f49 100644 --- a/go/internal/control/ev_deadline_runtime_test.go +++ b/go/internal/control/ev_deadline_runtime_test.go @@ -202,3 +202,65 @@ func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) { } t.Logf("%d real sends across6 ticks: final1p %.0fW +3p %.0fW, worst phase%.2fA", sends, actualA, actualB, actualA/230+actualB/690) } + +// 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. +func TestJoinedNoPlanGoalSmallCarGetsChargerMinimum(t *testing.T) { + ctx := context.Background() + for _, maxW := range []float64{11040, 22080} { + now := time.Now() + cfg := loadpoint.Config{ID: "garage", DriverName: "charger", VehicleCapacityWh: 15000, PluginSoC: .3, MinChargeW: 4140, MaxChargeW: maxW} + 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}) || + !mgr.SetCurrentSoC(cfg.ID, .3) || !mgr.SetTarget(cfg.ID, .8, now.Add(3*time.Hour)) { + t.Fatal("goal fixture rejected") + } + svc := &mpc.Service{LoadpointStates: mgr.States} + tel := telemetry.NewStore() + phaseA := 1000.0 / (3 * 230) + meterData, _ := json.Marshal(map[string]float64{"l1_a": phaseA, "l2_a": phaseA, "l3_a": phaseA}) + tel.Update("meter", telemetry.DerMeter, 1000, nil, meterData) + tel.RecordDriverSuccess("meter") + tel.Update(cfg.DriverName, telemetry.DerEV, 0, nil, json.RawMessage(`{"connected":true,"request_active":true}`)) + tel.RecordDriverSuccess(cfg.DriverName) + st := NewState(0, 0, "meter") + st.Mode = ModePlannerArbitrage + st.UseEnergyDispatch = true + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + var commands []float64 + controller := loadpoint.NewController(mgr, + func(at time.Time) (loadpoint.Directive, bool) { + d, ok := svc.SlotDirectiveAt(at) + return d.LoadpointDirective(), ok + }, + func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: 0, PowerAt: now}, true + }, + func(_ context.Context, _ string, p []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(p, &cmd); err != nil { + return err + } + if cmd.Action == "ev_set_current" { + commands = append(commands, cmd.PowerW) + } + return nil + }) + controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + 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) + } + } +} diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index 18efe87c3..19f17ed7a 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/sitesim" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -54,6 +55,7 @@ const ( usePause fieldUse = "pause" // Charge now, Pause for 25 s, then Start useRestart fieldUse = "restart" // a planned charge across a 20 s Core restart usePhaseUp fieldUse = "1p-to-3p" // a held 1p order changed to 3p after 5 min + useNoPlan fieldUse = "noplan" // a ready-by goal with no plan: Core's deadline reserve, the car's own maximum known ) // fieldCase is one site and one request. The name is stable: the known rows @@ -292,6 +294,13 @@ func probeCases() []fieldCase { c.carPhases, c.needW = 1, 1000 out = append(out, c) } + for _, tm := range []fieldTiming{timingFast, timingEasee} { + // A plug-in hybrid whose own charger (3,680 W) takes less than the + // charger's minimum, with the planner down. + c := homeBox(useNoPlan, tm) + c.carPhases = 1 + out = append(out, c) + } c := helpReport(usePlanned, timingEasee) c.houseStep = 30 * time.Minute return append(out, c) @@ -738,6 +747,13 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) f.plan = &fieldPlan{id: lp.ID, deadline: start.Add(run), run: f, planning: func() []float64 { return fieldPlanningSteps(s) }, fixedW: c.planW, needWh: math.Min(askW*run.Hours(), .8*roomW*run.Hours())} 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() }} + s.plan = func(now time.Time) (loadpoint.Directive, bool) { + d, ok := svc.SlotDirectiveAt(now) + return d.LoadpointDirective(), ok + } case usePause: run, kettle = 15*time.Minute, []time.Duration{11 * time.Minute} actions = []fieldAction{ @@ -806,6 +822,11 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) deadline := start.Add(run).Local() s.mgr.SetSchedule(lp.ID, loadpoint.NewSchedule(1, deadline.Hour()*60+deadline.Minute(), false, 0, false)) } + if c.use == useNoPlan { + // The goal is out of reach in the run, so the car always asks. + s.mgr.SetCarBinding(lp.ID, loadpoint.CarBinding{ID: "car", MaxW: s.p.Car.MaxW}) + s.mgr.SetTarget(lp.ID, .8, start.Add(3*time.Hour)) + } if c.battery { fieldBatteryPlan(s, 6000) } diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index 034287090..dd1a8f2a6 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -42,9 +42,10 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { // LimitPower holds a loadpoint's power to the session car's own charger: the // lower of the charger's and the car's maximum, and the charger's steps up to // it. When the charger's lowest step is above the car's maximum it keeps that -// one step: the car takes what it can, and a plan with no step would never -// charge. The minimum is never below the lowest step: PlanningSteps leaves -// out levels the charger's phases cannot run. +// one step, or without steps the charger's minimum: the car takes what it +// can, and a plan with no step would never charge. The minimum is never below +// the lowest step: PlanningSteps leaves out levels the charger's phases cannot +// run. func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { minW, maxW = st.MinChargeW, st.MaxChargeW lowest := 0.0 @@ -72,6 +73,9 @@ func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { out = append(out, lowest) maxW = lowest } + if lowest == 0 && minW > maxW { + maxW = minW + } if minW > maxW { minW = maxW } diff --git a/go/internal/loadpoint/deadline_need_test.go b/go/internal/loadpoint/deadline_need_test.go index d4bbb007d..d8340dd66 100644 --- a/go/internal/loadpoint/deadline_need_test.go +++ b/go/internal/loadpoint/deadline_need_test.go @@ -23,6 +23,10 @@ func TestLimitPowerKeepsTheChargersLowestStep(t *testing.T) { if _, maxW, steps = LimitPower(st, []float64{4140, 7360, 11000}); len(steps) != 3 || maxW != 11000 { t.Fatalf("a car above the charger: %v %v", maxW, steps) } + st.CarMaxW = 3680 + if minW, maxW, steps = LimitPower(st, nil); len(steps) != 0 || maxW != 4140 || minW != 4140 { + t.Fatalf("a 3.7 kW car on a stepless charger: power %v–%v %v, want the charger's minimum", minW, maxW, steps) + } } // The reserve's need and on-power hold to the session car's own charger and @@ -41,6 +45,11 @@ func TestDeadlineNeedHoldsToTheCar(t *testing.T) { 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 { + 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 { t.Fatalf("a 60 %% limit: %v %v", need, ok) diff --git a/go/internal/mpc/deadline_directive_test.go b/go/internal/mpc/deadline_directive_test.go index ccc4c89d7..11da0195d 100644 --- a/go/internal/mpc/deadline_directive_test.go +++ b/go/internal/mpc/deadline_directive_test.go @@ -115,3 +115,19 @@ func TestDeadlineReservePreservesAcceptedLegacyZero(t *testing.T) { t.Fatalf("known cheap-future zero turned into a charge reserve: %+v", d) } } + +// A car whose own charger takes less than a stepless charger's minimum, such +// as a 3.7 kW plug-in hybrid on an 11 or 22 kW charger, gets the charger's +// minimum without a plan, as it does with one: the car takes what it can. +func TestGoalReserveGivesASmallCarTheChargersMinimum(t *testing.T) { + now := time.Now() + for _, maxW := range []float64{11040, 22080} { + st := deadlineGoalState(now, "a") + st.MinChargeW, st.MaxChargeW, st.AllowedStepsW, st.CarMaxW = 4140, maxW, nil, 3680 + svc := &Service{LoadpointStates: func() []loadpoint.State { return []loadpoint.State{st} }} + d, ok := svc.SlotDirectiveAt(now) + if !ok || !d.LoadpointFallback["a"] || d.LoadpointMaxPowerW["a"] != 4140 || d.LoadpointEnergyWh["a"] <= 0 { + t.Fatalf("%.0f W charger: reserve %+v ok=%v, want 4140 W", maxW, d, ok) + } + } +}