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-one-phase-car-plan-floor.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 4 additions & 6 deletions go/cmd/ftw/ev_planning_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
37 changes: 34 additions & 3 deletions go/cmd/ftw/ev_planning_limits_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 17 additions & 5 deletions go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions go/internal/loadpoint/one_phase_car_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
29 changes: 28 additions & 1 deletion go/internal/loadpoint/planning_steps.go
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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) }
Expand Down
Loading