From ed675d730825d2d0279f70fd2828cc39320169c5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 16:29:43 +0200 Subject: [PATCH] fix(loadpoint): the deadline reserve orders a small car the charger's minimum, not 0 W With no plan, the deadline reserve held a car to its own maximum with LimitPower(st, st.AllowedStepsW). Without a step table there was no lowest step to keep, so a 3,680 W plug-in hybrid on a 4,140-11,040 W charger got 3,680-3,680 W, and SnapChargeW returned 0 because max < min. The plan path gets PlanningSteps' ladder and already orders 4,140 W. LimitPower now keeps the charger's minimum as that one step when there are no steps, so the reserve orders what the plan orders and the car takes what it can. Tests fail on master 16c4ad09: LimitPower and DeadlineNeed, the reserve through SlotDirectiveAt (11 and 22 kW chargers), the controller's order, and two field-matrix rows with a new use, noplan (0 W, idle 55 min on master; 2.3 kWh in 1 h here). The known list is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../ev-deadline-car-below-charger-min.md | 5 ++ .../control/ev_deadline_runtime_test.go | 62 +++++++++++++++++++ go/internal/control/sitesim_field_test.go | 21 +++++++ go/internal/loadpoint/deadline_need.go | 10 ++- go/internal/loadpoint/deadline_need_test.go | 9 +++ go/internal/mpc/deadline_directive_test.go | 16 +++++ 6 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 .changeset/ev-deadline-car-below-charger-min.md 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) + } + } +}