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 00000000..8ddecb34 --- /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 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.go b/go/cmd/ftw/ev_planning_limits.go index 570daee7..ff770673 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 36795b90..6881ccc3 100644 --- a/go/cmd/ftw/ev_planning_limits_test.go +++ b/go/cmd/ftw/ev_planning_limits_test.go @@ -1,23 +1,54 @@ package main import ( + "math" + "slices" + "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. 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) + 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}} + 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 !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/cmd/ftw/main.go b/go/cmd/ftw/main.go index b79ff7e6..a8a01fd0 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 c4294011..18efe87c 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) @@ -372,13 +380,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 @@ -723,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 d1c46897..35a26888 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -242,3 +242,109 @@ 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 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) { + 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, 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 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) + } + } + // 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) + } +} + +// 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 80c00eb2..1e2614a2 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: @@ -46,6 +49,30 @@ 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 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) { + 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, planning) +} + // 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) }