Skip to content
Open
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-deadline-car-below-charger-min.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions go/internal/control/ev_deadline_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
21 changes: 21 additions & 0 deletions go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 7 additions & 3 deletions go/internal/loadpoint/deadline_need.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions go/internal/loadpoint/deadline_need_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions go/internal/mpc/deadline_directive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading