From 5fda29bb122676c0d6ad96e2a25a331397644082 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 13:36:49 +0200 Subject: [PATCH 1/2] fix(loadpoint): plan a one-phase car above the charger's minimum, not at it Once a car was marked one-phase, the planner got its one-phase levels (1,380-3,680 W) with the charger's own minimum, 4,140 W on a charger set up for three phases. The planner drops every level below its minimum, so it had only 0 and the car stopped charging: device-simulator rig s14 on 2e847104, 0 W for the last 15 min with the goal not reached. The order for a one-phase car is three times the plan, so the plan's minimum is a third of the charger's: 6 A on one phase. Its maximum is the top one-phase level. loadpoint.PlanningLimits holds this, and main.go and the field matrix both use it. The field matrix planned with PlanningSteps alone: it never took the one-phase branch in main.go and ignored the minimum the planner drops levels below. It now uses PlanningLimits and drops levels outside the minimum and maximum as mpc does; without the fix 24 sweep rows (one-phase car, planned, a charger with a 4,140 W minimum) fail on energy. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-one-phase-car-plan-floor.md | 5 +++ go/cmd/ftw/ev_planning_limits.go | 10 ++--- go/cmd/ftw/ev_planning_limits_test.go | 38 +++++++++++++++++-- go/cmd/ftw/main.go | 2 +- go/internal/control/sitesim_field_test.go | 9 +++-- go/internal/loadpoint/one_phase_car_test.go | 41 +++++++++++++++++++++ go/internal/loadpoint/planning_steps.go | 16 ++++++++ 7 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 .changeset/ev-one-phase-car-plan-floor.md diff --git a/.changeset/ev-one-phase-car-plan-floor.md b/.changeset/ev-one-phase-car-plan-floor.md new file mode 100644 index 000000000..680f678c4 --- /dev/null +++ b/.changeset/ev-one-phase-car-plan-floor.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A car that charges on one phase of a three-phase charger is planned again once FTW sees it. FTW plans such a car at 1.4–3.7 kW and orders three times that, but it gave the planner the charger's three-phase minimum of 4.1 kW with those levels. The planner dropped every level below that minimum and planned no charging, so the car stopped about five minutes into the session and missed its goal. The planner now gets a third of the charger's minimum, which is the car's 6 A on its one phase. diff --git a/go/cmd/ftw/ev_planning_limits.go b/go/cmd/ftw/ev_planning_limits.go index 570daee76..ff7706735 100644 --- a/go/cmd/ftw/ev_planning_limits.go +++ b/go/cmd/ftw/ev_planning_limits.go @@ -5,7 +5,9 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" ) -func planningStepsForLoadpoint(st loadpoint.State, cfg *config.Config) []float64 { +// planningLimitsForLoadpoint is the power range and the steps the planner +// gets for a loadpoint's session. +func planningLimitsForLoadpoint(st loadpoint.State, cfg *config.Config) (minW, maxW float64, steps []float64) { lp := loadpoint.Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW} for _, configured := range cfg.Loadpoints { if configured.ID == st.ID { @@ -14,9 +16,5 @@ func planningStepsForLoadpoint(st loadpoint.State, cfg *config.Config) []float64 } } site := loadpoint.SiteFuse{MaxAmps: cfg.Fuse.MaxAmps, Voltage: cfg.Fuse.Voltage, PhaseCnt: cfg.Fuse.Phases} - if st.CarPhases == 1 { - // A one-phase car takes one phase of the charger's current. - return loadpoint.OnePhaseCarSteps(lp, site) - } - return loadpoint.PlanningSteps(lp, site) + return loadpoint.PlanningLimits(st, lp, site) } diff --git a/go/cmd/ftw/ev_planning_limits_test.go b/go/cmd/ftw/ev_planning_limits_test.go index 36795b906..17fad0b9a 100644 --- a/go/cmd/ftw/ev_planning_limits_test.go +++ b/go/cmd/ftw/ev_planning_limits_test.go @@ -1,23 +1,55 @@ package main import ( + "testing" + "time" + "github.com/srcfl/ftw/go/internal/config" "github.com/srcfl/ftw/go/internal/loadpoint" - "testing" + "github.com/srcfl/ftw/go/internal/mpc" ) func TestConfiguredEVLimitsReachPlanner(t *testing.T) { cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 16, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{ID: "garage", PhaseMode: "1p"}}} st := loadpoint.State{ID: "garage", MinChargeW: 1380, MaxChargeW: 3680} - steps := planningStepsForLoadpoint(st, cfg) + _, _, steps := planningLimitsForLoadpoint(st, cfg) if len(steps) != 12 || steps[1] != 1380 || steps[len(steps)-1] != 3680 { t.Fatalf("1-phase limits lost on 3-phase site: %v", steps) } cfg.Loadpoints[0].PhaseMode = "3p" st.MinChargeW = 4140 st.MaxChargeW = 11000 - steps = planningStepsForLoadpoint(st, cfg) + _, _, steps = planningLimitsForLoadpoint(st, cfg) if steps[1] != 4140 || steps[len(steps)-1] != 10350 { t.Fatalf("3-phase limits lost: %v", steps) } } + +// Device-simulator rig s14: once the car was marked one-phase the plan had +// no charging for it. The limits main.go gives the planner must leave it a +// level to charge at, and the planner must use it. +func TestOnePhaseCarGetsAPlan(t *testing.T) { + cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{ID: "garage", PhaseMode: "auto"}}} + st := loadpoint.State{ID: "garage", MinChargeW: 4140, MaxChargeW: 11000, CarPhases: 1} + minW, maxW, steps := planningLimitsForLoadpoint(st, cfg) + start := time.Now().Truncate(15 * time.Minute) + slots := make([]mpc.Slot, 8) + for i := range slots { + slots[i] = mpc.Slot{StartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(), LenMin: 15, PriceOre: 100, + Limits: mpc.PowerLimits{MaxImportW: 17250}} + } + p := mpc.Params{Mode: mpc.ModeArbitrage, CapacityWh: 10000, SoCMin: .1, SoCMax: .9, InitialSoC: .5, ChargeEfficiency: 1, DischargeEfficiency: 1, + Loadpoint: &mpc.LoadpointSpec{ID: "garage", CapacityWh: 60000, Levels: 11, SoCMax: .55, InitialSoC: .5, TargetSoC: .55, TargetSlotIdx: 7, + PluggedIn: true, ChargeEfficiency: 1, MinChargeW: minW, MaxChargeW: maxW, AllowedStepsW: steps}} + plan := mpc.Optimize(slots, p) + var wh float64 + for _, a := range plan.Actions { + wh += a.LoadpointW * 0.25 + } + if wh < 1380*0.25 { + t.Fatalf("the plan charges the car %.0f Wh toward a 3,000 Wh goal (limits %.0f-%.0f W)", wh, minW, maxW) + } + if minW != 1380 || maxW != 3450 { + t.Fatalf("one-phase car planned at %.0f-%.0f W, want 1380-3450 W", minW, maxW) + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index b79ff7e65..a8a01fd04 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1789,7 +1789,7 @@ func main() { noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() charging, chargeDuration := lpMgr.ChargingPeriod(st.ID) - minW, maxW, steps := loadpoint.LimitPower(st, planningStepsForLoadpoint(st, cfg)) + minW, maxW, steps := planningLimitsForLoadpoint(st, cfg) specs = append(specs, &mpc.LoadpointSpec{ ID: st.ID, CapacityWh: capWh, diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index c4294011a..a47b1f28c 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -372,13 +372,14 @@ func (p *fieldPlan) directive(now time.Time) (loadpoint.Directive, bool) { return d, true } -// fieldPlanningSteps is what main.go gives the planner for the session: -// planningStepsForLoadpoint's steps, held to the car by LimitPower. +// fieldPlanningSteps is what the planner can choose for the session: the +// steps main.go gives it (planningLimitsForLoadpoint), within the minimum +// and maximum it gets with them, as mpc's normalizedSteps keeps them. func fieldPlanningSteps(s *simSite) []float64 { st, _ := s.mgr.State(s.lp.ID) cfg := loadpoint.Config{MinChargeW: st.MinChargeW, MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, PhaseMode: s.lp.PhaseMode} - _, _, steps := loadpoint.LimitPower(st, loadpoint.PlanningSteps(cfg, loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3})) - return steps + minW, maxW, steps := loadpoint.PlanningLimits(st, cfg, loadpoint.SiteFuse{MaxAmps: s.p.Fuse.RatingA, Voltage: s.p.Voltage, PhaseCnt: 3}) + return slices.DeleteFunc(slices.Clone(steps), func(w float64) bool { return w > 0 && (w < minW || maxW > 0 && w > maxW) }) } // asks reports whether the plan wants the car charging now: its slot has an diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go index d1c468978..987b181a1 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -242,3 +242,44 @@ func TestOnePhaseCarMarkedAfterARestart(t *testing.T) { } } } + +// Device-simulator rig s14 on 2e847104: a three-phase charger with the +// settings page's minimum, 4,140 W. Once the car was marked one-phase the +// planner got its one-phase levels with that minimum, dropped every level +// below it and planned nothing. The plan's minimum is a third of the +// charger's, as the order is three times the plan. +func TestOnePhaseCarPlanningLimits(t *testing.T) { + site := SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3} + planned := func(minW, maxW float64, steps []float64) (n int) { + for _, w := range steps { + if w > 0 && w >= minW && w <= maxW { + n++ + } + } + return n + } + for _, c := range []struct { + name string + cfg Config + carMaxW float64 + wantMin, wantMax float64 + wantPlannedLevels int + }{ + {"settings page's charger", Config{MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "auto"}, 0, 1380, 3450, 10}, + {"three-phase table", Config{MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 6900, 11040}}, 0, 1380, 3680, 11}, + {"field table", Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: easeeFieldSteps}, 0, 1380, 3680, 11}, + {"car's own maximum", Config{MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "auto"}, 3000, 1380, 3000, 8}, + } { + st := State{MinChargeW: c.cfg.MinChargeW, MaxChargeW: c.cfg.MaxChargeW, AllowedStepsW: c.cfg.AllowedStepsW, CarMaxW: c.carMaxW, CarPhases: 1} + minW, maxW, steps := PlanningLimits(st, c.cfg, site) + if minW != c.wantMin || maxW != c.wantMax || planned(minW, maxW, steps) != c.wantPlannedLevels { + t.Errorf("%s: min %.0f max %.0f, %d levels the planner keeps (%v); want %.0f, %.0f, %d", + c.name, minW, maxW, planned(minW, maxW, steps), steps, c.wantMin, c.wantMax, c.wantPlannedLevels) + } + } + // An unmarked car keeps the charger's limits. + cfg := Config{MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "auto"} + if minW, maxW, _ := PlanningLimits(State{MinChargeW: 4140, MaxChargeW: 11000}, cfg, site); minW != 4140 || maxW != 11000 { + t.Fatalf("unmarked car: %.0f-%.0f W", minW, maxW) + } +} diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index 80c00eb28..1157d656c 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -46,6 +46,22 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { return steps } +// PlanningLimits is the power range and the steps the planner gets for a +// loadpoint's session, held to the car by LimitPower. A one-phase car is +// planned at what it takes: 6 A up to the charger's current per phase, on +// one phase. Its order is three times the plan, so the charger's minimum, +// which holds for the order, is a third as much in the plan; a charger's +// three-phase minimum would leave the planner no step but 0. +func PlanningLimits(st State, cfg Config, site SiteFuse) (minW, maxW float64, steps []float64) { + if st.CarPhases == 1 { + if steps := OnePhaseCarSteps(cfg, site); len(steps) > 1 { + st.MinChargeW, st.MaxChargeW = st.MinChargeW/3, steps[len(steps)-1] + return LimitPower(st, steps) + } + } + return LimitPower(st, PlanningSteps(cfg, site)) +} + // 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) } From eb42e22d1ffa1d6cbee116252bfb090ccf82dfe2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 14:06:32 +0200 Subject: [PATCH 2/2] fix(loadpoint): a one-phase car's levels are thirds of the charger's three-phase steps Review of #1806: a third of the charger's minimum gave the planner a 1,380 W level on the field table, whose minimum (1,600 W) is a one-phase one. Three times it, 4,140 W, is not on the table, so a plan slot at that level with the plan's peak went out as 0 W; on coarse tables the car drew up to 30 % less than the plan counted. A marked one-phase car's levels are now the charger's three-phase planning steps divided by three, up to its one-phase maximum, so each is a third of an order the table and minimum allow. Without a table nothing changes: 4,140 W gives 1,380 W, 4,800 W gives 1,610 W. PlanningLimits also needs a charger that can run three phases (threePhaseCapable, which includes a three-phase site). TestOnePhaseCarLevelsAreOrders ticks the controller at every level the planner keeps, for five chargers and two fuses. Two field-matrix rows ask a small goal of the one-phase car on the help report's table; both were idle with room on the previous head. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-one-phase-car-plan-floor.md | 2 +- go/cmd/ftw/ev_planning_limits_test.go | 23 +++---- go/internal/control/sitesim_field_test.go | 13 +++- go/internal/loadpoint/one_phase_car_test.go | 75 +++++++++++++++++++-- go/internal/loadpoint/planning_steps.go | 33 ++++++--- 5 files changed, 116 insertions(+), 30 deletions(-) diff --git a/.changeset/ev-one-phase-car-plan-floor.md b/.changeset/ev-one-phase-car-plan-floor.md index 680f678c4..8ddecb34e 100644 --- a/.changeset/ev-one-phase-car-plan-floor.md +++ b/.changeset/ev-one-phase-car-plan-floor.md @@ -2,4 +2,4 @@ "ftw": patch --- -A car that charges on one phase of a three-phase charger is planned again once FTW sees it. FTW plans such a car at 1.4–3.7 kW and orders three times that, but it gave the planner the charger's three-phase minimum of 4.1 kW with those levels. The planner dropped every level below that minimum and planned no charging, so the car stopped about five minutes into the session and missed its goal. The planner now gets a third of the charger's minimum, which is the car's 6 A on its one phase. +A car that charges on one phase of a three-phase charger is planned again once FTW sees it. FTW plans such a car at a third of each three-phase order, but it gave the planner the charger's three-phase minimum, 4.1 kW, with those one-phase levels. The planner dropped every level below that minimum and planned no charging, so the car stopped about five minutes into the session and missed its goal. The planner now gets the charger's three-phase levels divided by three, for example 1.4–3.7 kW. Each level is a third of an order the charger accepts, so the car draws what the plan counts. diff --git a/go/cmd/ftw/ev_planning_limits_test.go b/go/cmd/ftw/ev_planning_limits_test.go index 17fad0b9a..6881ccc39 100644 --- a/go/cmd/ftw/ev_planning_limits_test.go +++ b/go/cmd/ftw/ev_planning_limits_test.go @@ -1,6 +1,8 @@ package main import ( + "math" + "slices" "testing" "time" @@ -26,9 +28,10 @@ func TestConfiguredEVLimitsReachPlanner(t *testing.T) { } // Device-simulator rig s14: once the car was marked one-phase the plan had -// no charging for it. The limits main.go gives the planner must leave it a -// level to charge at, and the planner must use it. -func TestOnePhaseCarGetsAPlan(t *testing.T) { +// no charging for it. With the limits main.go gives the planner, the plan +// charges the car in at least one slot at a level Core can order: one of +// the car's levels, three times which is within the charger's range. +func TestOnePhaseCarPlanChargesAtALevelCoreCanOrder(t *testing.T) { cfg := &config.Config{Fuse: config.Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}, Loadpoints: []config.Loadpoint{{ID: "garage", PhaseMode: "auto"}}} st := loadpoint.State{ID: "garage", MinChargeW: 4140, MaxChargeW: 11000, CarPhases: 1} minW, maxW, steps := planningLimitsForLoadpoint(st, cfg) @@ -41,15 +44,11 @@ func TestOnePhaseCarGetsAPlan(t *testing.T) { p := mpc.Params{Mode: mpc.ModeArbitrage, CapacityWh: 10000, SoCMin: .1, SoCMax: .9, InitialSoC: .5, ChargeEfficiency: 1, DischargeEfficiency: 1, Loadpoint: &mpc.LoadpointSpec{ID: "garage", CapacityWh: 60000, Levels: 11, SoCMax: .55, InitialSoC: .5, TargetSoC: .55, TargetSlotIdx: 7, PluggedIn: true, ChargeEfficiency: 1, MinChargeW: minW, MaxChargeW: maxW, AllowedStepsW: steps}} - plan := mpc.Optimize(slots, p) - var wh float64 - for _, a := range plan.Actions { - wh += a.LoadpointW * 0.25 + orderable := func(w float64) bool { + return w > 0 && slices.ContainsFunc(steps, func(s float64) bool { return math.Abs(s-w) < 1 }) && + 3*w >= st.MinChargeW && 3*w <= st.MaxChargeW } - if wh < 1380*0.25 { - t.Fatalf("the plan charges the car %.0f Wh toward a 3,000 Wh goal (limits %.0f-%.0f W)", wh, minW, maxW) - } - if minW != 1380 || maxW != 3450 { - t.Fatalf("one-phase car planned at %.0f-%.0f W, want 1380-3450 W", minW, maxW) + if !slices.ContainsFunc(mpc.Optimize(slots, p).Actions, func(a mpc.Action) bool { return orderable(a.LoadpointW) }) { + t.Fatalf("no slot charges the car at a level Core can order (limits %.0f-%.0f W, levels %v)", minW, maxW, steps) } } diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index a47b1f28c..18efe87c3 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -75,6 +75,7 @@ type fieldCase struct { charger string // the charger itself when not what the phase mode says: "1p32A" planW float64 // > 0: the plan asks this many W each slot, as Wh only + needW float64 // > 0: the goal asks this many W on average goal bool // surplus-only with a ready-by goal exportW []float64 // surplus: export each quarter hour houseStep time.Duration // > 0: L1 +8 A for 10 min from this time into the run @@ -94,7 +95,7 @@ func (c fieldCase) key() string { on bool text string }{ - {c.battery, "battery"}, {c.charger != "", "charger" + c.charger}, {c.planW > 0, fmt.Sprintf("plan%gW", c.planW)}, + {c.battery, "battery"}, {c.charger != "", "charger" + c.charger}, {c.planW > 0, fmt.Sprintf("plan%gW", c.planW)}, {c.needW > 0, fmt.Sprintf("need%gW", c.needW)}, {c.goal, "goal"}, {export != "", export}, {c.houseStep > 0, "L1step"}, } { if v.on { @@ -284,6 +285,13 @@ func probeCases() []fieldCase { out = append(out, c) } } + for _, tm := range []fieldTiming{timingFast, timingEasee} { + // A small goal for the one-phase car: the plan picks its lowest + // level, which must be a third of an order the table has. + c := helpReport(usePlanned, tm) + c.carPhases, c.needW = 1, 1000 + out = append(out, c) + } c := helpReport(usePlanned, timingEasee) c.houseStep = 30 * time.Minute return append(out, c) @@ -724,6 +732,9 @@ func runFieldCase(c fieldCase, trace func(string, ...any)) (*simSite, *fieldRun) if c.planW > 0 { askW = c.planW } + if c.needW > 0 { + askW = c.needW + } 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 diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go index 987b181a1..35a268884 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -246,8 +246,8 @@ func TestOnePhaseCarMarkedAfterARestart(t *testing.T) { // Device-simulator rig s14 on 2e847104: a three-phase charger with the // settings page's minimum, 4,140 W. Once the car was marked one-phase the // planner got its one-phase levels with that minimum, dropped every level -// below it and planned nothing. The plan's minimum is a third of the -// charger's, as the order is three times the plan. +// below it and planned nothing. The car's levels are the charger's +// three-phase steps divided by three, up to its one-phase maximum. func TestOnePhaseCarPlanningLimits(t *testing.T) { site := SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3} planned := func(minW, maxW float64, steps []float64) (n int) { @@ -266,13 +266,14 @@ func TestOnePhaseCarPlanningLimits(t *testing.T) { wantPlannedLevels int }{ {"settings page's charger", Config{MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "auto"}, 0, 1380, 3450, 10}, - {"three-phase table", Config{MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 6900, 11040}}, 0, 1380, 3680, 11}, - {"field table", Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: easeeFieldSteps}, 0, 1380, 3680, 11}, + {"three-phase table", Config{MinChargeW: 4140, MaxChargeW: 11040, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 6900, 11040}}, 0, 1380, 3680, 3}, + {"field table", Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: easeeFieldSteps}, 0, 1600, 10400.0 / 3, 9}, + {"minimum 4,800 W", Config{MinChargeW: 4800, MaxChargeW: 11000, PhaseMode: "auto"}, 0, 1610, 3450, 9}, {"car's own maximum", Config{MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "auto"}, 3000, 1380, 3000, 8}, } { st := State{MinChargeW: c.cfg.MinChargeW, MaxChargeW: c.cfg.MaxChargeW, AllowedStepsW: c.cfg.AllowedStepsW, CarMaxW: c.carMaxW, CarPhases: 1} minW, maxW, steps := PlanningLimits(st, c.cfg, site) - if minW != c.wantMin || maxW != c.wantMax || planned(minW, maxW, steps) != c.wantPlannedLevels { + if math.Abs(minW-c.wantMin) > .5 || math.Abs(maxW-c.wantMax) > .5 || planned(minW, maxW, steps) != c.wantPlannedLevels { t.Errorf("%s: min %.0f max %.0f, %d levels the planner keeps (%v); want %.0f, %.0f, %d", c.name, minW, maxW, planned(minW, maxW, steps), steps, c.wantMin, c.wantMax, c.wantPlannedLevels) } @@ -283,3 +284,67 @@ func TestOnePhaseCarPlanningLimits(t *testing.T) { t.Fatalf("unmarked car: %.0f-%.0f W", minW, maxW) } } + +// Review of #1806: every level the planner keeps for a one-phase car is a +// third of an order the charger's table has. A plan slot at that level goes +// out as that order, within the charger and the fuse, and the car draws +// what the plan counts. A third of the charger's minimum was not: on the +// field table 1,380 W became 4,140 W, which the table lacks, and 0 W went +// out; coarse tables drew up to 30 % less than the plan. +func TestOnePhaseCarLevelsAreOrders(t *testing.T) { + for _, ch := range []struct { + name string + minW, maxW float64 + steps []float64 + }{ + {"settings page", 4140, 11000, nil}, + {"field table", 1600, 11100, easeeFieldSteps}, + {"minimum 4,800 W", 4800, 11000, nil}, + {"coarse table", 4800, 11000, []float64{0, 4800, 7200, 11000}}, + {"three-phase table", 4140, 11040, []float64{0, 4140, 6900, 11040}}, + } { + for _, fuseA := range []float64{16, 25} { + site := SiteFuse{MaxAmps: fuseA, Voltage: 230, PhaseCnt: 3} + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: ch.minW, MaxChargeW: ch.maxW, PhaseMode: "auto", AllowedStepsW: ch.steps} + st := State{MinChargeW: ch.minW, MaxChargeW: ch.maxW, AllowedStepsW: ch.steps, CarPhases: 1} + minW, maxW, steps := PlanningLimits(st, cfg, site) + kept := 0 + for _, level := range steps { + if level <= 0 || level < minW || level > maxW { + continue // The planner drops it (mpc normalizedSteps). + } + kept++ + power, expected := onePhaseCarOrder(cfg, site, level) + if power <= 0 || power > ch.maxW || power/690 > fuseA+1e-6 || math.Abs(expected-level) > level/100 { + t.Errorf("%s, %g A: a plan slot at %.0f W went out as %.0f W, the car drawing %.0f W", + ch.name, fuseA, level, power, expected) + } + } + if kept == 0 { + t.Errorf("%s, %g A: the planner keeps no level of %v (%.0f-%.0f W)", ch.name, fuseA, steps, minW, maxW) + } + } + } +} + +// onePhaseCarOrder is the order a marked one-phase car's plan slot at +// levelW goes out as, with the plan's peak, and the draw it names. +func onePhaseCarOrder(cfg Config, site SiteFuse, levelW float64) (powerW, expectedW float64) { + slot := time.Now().Add(-time.Second) + dir := &Directive{SlotStart: slot, SlotEnd: slot.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{cfg.ID: levelW / 4}, LoadpointMaxPowerW: map[string]float64{cfg.ID: levelW}} + var payload map[string]any + send := func(_ context.Context, _ string, p []byte) error { return json.Unmarshal(p, &payload) } + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + c := NewController(NewManager(), PlanFunc(func(time.Time) (Directive, bool) { return *dir, true }), + TelemetryFunc(func(d string) (EVSample, bool) { s, ok := samples[d]; return s, ok }), send) + c.manager.Load([]Config{cfg}) + c.SetSiteFuse(site) + c.manager.Observe(cfg.ID, true, 0, 0, true) + c.manager.SetSchedule(cfg.ID, Schedule{SoC: .8}) + c.manager.byID[cfg.ID].carPhases.phase = 1 + c.Tick(context.Background(), time.Now()) + powerW, _ = payload["power_w"].(float64) + expectedW, _ = payload["expected_w"].(float64) + return powerW, expectedW +} diff --git a/go/internal/loadpoint/planning_steps.go b/go/internal/loadpoint/planning_steps.go index 1157d656c..1e2614a2c 100644 --- a/go/internal/loadpoint/planning_steps.go +++ b/go/internal/loadpoint/planning_steps.go @@ -1,6 +1,9 @@ package loadpoint -import "math" +import ( + "math" + "slices" +) // PlanningSteps exposes legal whole-amp offers for a scheduled charge. // Scheduled charging runs on the car's phases, as resolvePhaseMode sends it: @@ -47,19 +50,27 @@ func PlanningSteps(cfg Config, site SiteFuse) []float64 { } // PlanningLimits is the power range and the steps the planner gets for a -// loadpoint's session, held to the car by LimitPower. A one-phase car is -// planned at what it takes: 6 A up to the charger's current per phase, on -// one phase. Its order is three times the plan, so the charger's minimum, -// which holds for the order, is a third as much in the plan; a charger's -// three-phase minimum would leave the planner no step but 0. +// loadpoint's session, held to the car by LimitPower. A one-phase car on a +// three-phase charger takes a third of each three-phase order, so its +// levels are the charger's three-phase steps divided by three, up to its +// one-phase maximum: each is a third of an order the charger's table and +// minimum allow. The charger's own minimum would leave the planner no level. func PlanningLimits(st State, cfg Config, site SiteFuse) (minW, maxW float64, steps []float64) { - if st.CarPhases == 1 { - if steps := OnePhaseCarSteps(cfg, site); len(steps) > 1 { - st.MinChargeW, st.MaxChargeW = st.MinChargeW/3, steps[len(steps)-1] - return LimitPower(st, steps) + planning := PlanningSteps(cfg, site) + if st.CarPhases == 1 && threePhaseCapable(cfg, site) { + topW := OnePhaseCarMaxW(cfg, site) + levels := []float64{0} + for _, w := range planning { + if w > 0 && w/3 <= topW+1e-9 { + levels = append(levels, w/3) + } + } + if len(levels) > 1 { + st.MinChargeW, st.MaxChargeW = slices.Min(levels[1:]), slices.Max(levels) + return LimitPower(st, levels) } } - return LimitPower(st, PlanningSteps(cfg, site)) + return LimitPower(st, planning) } // ThreePhaseCapable reports whether a charger can run three phases at 6 A