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-deadline-continuity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Keep saved EV charging goals active when plans or prices are unavailable. Share one live fuse budget across chargers, keep legal charging steps, and reserve final charger offers before optional battery charging.
81 changes: 19 additions & 62 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1609,12 +1609,20 @@ func main() {
mpcSvc.SiteMeter = cfg.SiteMeterDriver()
// The mathematical planner co-optimizes every scheduled loadpoint.
// The service retains the first entry for its Go-DP emergency fallback.
mpcSvc.LoadpointStates = func() []loadpoint.State {
states := lpMgr.States()
if lpController != nil {
for i := range states {
if hold, ok := lpController.GetManualHold(states[i].ID, time.Now()); ok {
states[i].ManualActive, states[i].ManualChargeW = true, hold.PowerW
}
}
}
return states
}
mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec {
specs := make([]*mpc.LoadpointSpec, 0)
for _, st := range lpMgr.States() {
if lpController != nil {
lpController.SetGridDeferred(st.ID, false)
}
if !st.PluggedIn || st.ChargingDeclined {
continue
}
Expand Down Expand Up @@ -1711,60 +1719,11 @@ func main() {
if initSoC > maxSoC {
maxSoC = initSoC
}
// Defer grid-funded EV planning when the deadline lies
// past the last published price slot AND is more than ~3 h
// out. Without this, MPC commits today's afternoon grid
// (today's prices are confirmed) instead of waiting for
// tomorrow's pre-dawn slots (typically published ~13:00 UTC
// for next-day Nordpool). The user-facing intent is "burn
// surplus PV during day, only plan grid at night when the
// real cheap window is known". Setting LoadpointSpec.SurplusOnly
// makes MPC's DP refuse grid-import EV actions; the runtime
// loadpoint controller still grabs PV via the bat-SoC unlock
// path. Once tomorrow's prices land, MPC's next replan
// rebuilds the spec without this guard and proper grid
// planning kicks in.
deferGridPlan := false
if priceSvc != nil {
end := time.Now().Add(72 * time.Hour)
pts, _ := priceSvc.Load(time.Now().UnixMilli(), end.UnixMilli())
var latestEnd time.Time
for _, p := range pts {
slotEnd := time.UnixMilli(p.SlotTsMs).Add(time.Duration(p.SlotLenMin) * time.Minute)
if slotEnd.After(latestEnd) {
latestEnd = slotEnd
}
}
if !latestEnd.IsZero() &&
effectiveTargetTime.After(latestEnd) &&
time.Until(effectiveTargetTime) > 3*time.Hour {
deferGridPlan = true
}
}
slog.Debug("mpc: loadpoint spec",
"id", st.ID, "soc", initSoC, "soc_source", socSource,
"target", effectiveTarget, "target_slot", targetSlot,
"max_soc", maxSoC, "vehicle_limit", vehicleChargeLimit,
"defer_grid_plan", deferGridPlan)
if deferGridPlan {
slog.Info("mpc: LP grid-funded planning deferred — target past published prices",
"lp", st.ID, "target", effectiveTargetTime, "hours_to_target", time.Until(effectiveTargetTime).Hours())
}
// Mirror the deferral into the runtime controller so live
// dispatch enforces "no grid import" too. Without this,
// MPC's plan would still record a small EV budget per slot
// (snapped to forecast surplus); when forecast PV
// undershoots reality, the runtime controller would
// happily import grid to fulfil the cached plan budget.
if lpController != nil {
lpController.SetGridDeferred(st.ID, deferGridPlan)
}
// Surplus-only on the 48 h spec is the operator flag or
// the "deadline is past published prices" deferral.
// The bat-SoC unlock is a this-tick opportunistic clamp
// and must not poison night-time grid EV in a plan
// computed while the sun is still up. Battery→EV is
// already blocked by NoBatteryToEV below.
// Published prices choose among feasible times. Missing later
// prices never turn a saved deadline into a surplus-only goal.
slog.Debug("mpc: loadpoint spec", "id", st.ID, "soc", initSoC,
"soc_source", socSource, "target", effectiveTarget, "target_slot", targetSlot,
"max_soc", maxSoC, "vehicle_limit", vehicleChargeLimit)
ctrlMu.Lock()
noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive)
ctrlMu.Unlock()
Expand All @@ -1783,7 +1742,7 @@ func main() {
MaxChargeW: st.MaxChargeW,
AllowedStepsW: planningStepsForLoadpoint(st, cfg),
ChargeEfficiency: loadpoint.DefaultChargeEfficiency,
SurplusOnly: loadpoint.PlannerTreatsLoadpointAsSurplusOnly(st.SurplusOnly, deferGridPlan),
SurplusOnly: st.SurplusOnly,
NoBatteryToEV: noBatteryToEV,
Charging: mpc.DefaultChargingPeriods(charging, chargeDuration.Seconds()),
})
Expand Down Expand Up @@ -1965,10 +1924,7 @@ func main() {
lpController.SetFuseEVMax(func() (float64, bool) {
ctrlMu.Lock()
defer ctrlMu.Unlock()
if !ctrl.FuseSaturated {
return 0, false
}
return ctrl.FuseEVMaxW, true
return control.EVFuseBudget(tel, ctrl, lpMgr.States(), lpMgr.Configs(), ctrl.SiteFuseAmps*ctrl.SiteFuseVoltage*float64(ctrl.SiteFusePhases))
})
// Every lease is preflighted and re-evaluated on each loadpoint tick.
// This closure owns site/battery health because those concepts belong
Expand Down Expand Up @@ -3085,6 +3041,7 @@ func main() {
ctrl.EVCurtailHeadroomW = evCurtailHeadroomW
ctrl.BatteryBoostEVChargingW = boostEVW
ctrl.BatteryBoostReserveSoC = boostReserveSoC
ctrl.EVCommandedW = loadpoint.CommandedEVPowerW(lpStatesSnapshot)
fuseMaxW := ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases)
targets := control.ComputeDispatch(tel, ctrl, capsSnap, fuseMaxW)
planMissingNow := ctrl.Mode.IsPlannerMode() && ctrl.PlanStale
Expand Down
11 changes: 9 additions & 2 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type SlotDirective struct {
// slot-average Wh/time, or a short pulse can share the fuse with a
// battery charge that no longer fits when the charger actually turns on.
LoadpointMaxPowerW map[string]float64
EVOnly bool // Goal reserve; no accepted battery allocation.
}

// SlotDirectiveFunc returns the plan's energy-allocation directive for
Expand Down Expand Up @@ -362,6 +363,10 @@ type State struct {
// main.go refreshes both from loadpoint.Controller every dispatch tick.
BatteryBoostEVChargingW float64
BatteryBoostReserveSoC float64
// EVCommandedW is the frozen sum of final charger offers from this tick.
// It reserves site capacity before optional battery charging, even before
// an EV ramps up and in modes without an accepted optimizer plan.
EVCommandedW float64

// PI controller (outer, site-level)
PI *PIController
Expand Down Expand Up @@ -871,6 +876,7 @@ func preparePlannerSelf(state *State, now time.Time) plannerSelfDecision {
resetEnergyDispatchBookkeeping(state)

dir, planFresh := plannerSelfDirectiveAt(state, now)
planFresh = planFresh && !dir.EVOnly
if !planFresh {
if !state.PlanStale {
slog.Warn("planner_self: plan stale — discharge-only self_consumption (no self-charge until fresh plan)")
Expand Down Expand Up @@ -1260,7 +1266,7 @@ func ComputeDispatch(
case state.Mode.IsPlannerMode():
// planner_cheap / planner_arbitrage.
if state.UseEnergyDispatch && state.SlotDirective != nil {
if dir, ok := plannerSelfDirectiveAt(state, state.now()); ok {
if dir, ok := plannerSelfDirectiveAt(state, state.now()); ok && !dir.EVOnly {
state.controlSlotDecisionID = dir.DecisionID
state.controlPlanSnapshot.source = controlPlanDirective
currentDirective = dir
Expand Down Expand Up @@ -1484,6 +1490,7 @@ func ComputeDispatch(
}
}
if len(onlineBats) == 0 {
publishBatterylessEVFuseBudget(store, state, fuseMaxW)
state.LastTargets = nil
return nil
}
Expand Down Expand Up @@ -2034,7 +2041,7 @@ func ComputeDispatch(
if projectedGrid > ceilingW && (B+E) > 0 {
if plannedEVW > 0 {
// A timed EV goal takes its share before optional battery charge.
available := math.Max(0, ceilingW-H-Bn)
available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, state.EVChargingW)
state.FuseEVMaxW = math.Min(E, available)
newBattery := Bn + math.Min(B, math.Max(0, available-state.FuseEVMaxW))
totalCorrection = newBattery - currentTotal
Expand Down
206 changes: 206 additions & 0 deletions go/internal/control/ev_deadline_runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package control

import (
"context"
"encoding/json"
"math"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/telemetry"
)

func TestJoinedNoBatteryNoPlanGoalContinuity(t *testing.T) {
ctx := context.Background()
now := time.Now()
deadline := now.Add(3 * time.Hour)
cfg := loadpoint.Config{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 1380, MaxChargeW: 11000, PhaseMode: "3p", AllowedStepsW: []float64{0, 1380, 4140, 6900, 9660, 11000}}
mgr := loadpoint.NewManager()
mgr.Load([]loadpoint.Config{cfg})
mgr.Observe(cfg.ID, true, 0, 0, true)
if !mgr.SetCurrentSoC(cfg.ID, .3) || !mgr.SetTarget(cfg.ID, .8, deadline) {
t.Fatal("goal fixture rejected")
}
svc := &mpc.Service{LoadpointStates: mgr.States}
dir, ok := svc.SlotDirectiveAt(now)
if !ok || !dir.EVOnly || !dir.LoadpointFallback[cfg.ID] || svc.Latest() != nil {
t.Fatalf("missing EV-only goal intent: %+v ok=%v", dir, ok)
}
tel := telemetry.NewStore()
phaseA := 6000.0 / (3 * 230)
meterData, _ := json.Marshal(map[string]float64{"l1_a": phaseA, "l2_a": phaseA, "l3_a": phaseA})
tel.Update("meter", telemetry.DerMeter, 6000, 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
st.SlotDirective = func(at time.Time) (SlotDirective, bool) {
d, ok := svc.SlotDirectiveAt(at)
return SlotDirectiveFromMPC(d), ok
}
targets := ComputeDispatch(tel, st, nil, 17250)
if len(targets) != 0 {
t.Fatalf("no-battery fallback invented storage commands: %v", targets)
}
if !st.PlanStale {
t.Fatal("EV-only intent falsely cleared missing battery-plan status")
}
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.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return phaseA, phaseA, phaseA, true })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) })
controller.TickWithDispatch(ctx, now, true)
if len(commands) == 0 || commands[len(commands)-1] <= 0 {
t.Fatalf("goal stopped at integration boundary: %v", commands)
}
got := commands[len(commands)-1]
legal := false
for _, w := range cfg.AllowedStepsW {
if math.Abs(w-got) < 1e-6 {
legal = true
}
}
if !legal || 6000+got > 17250.001 || phaseA+got/(3*230) > 24.001 {
t.Fatalf("unsafe or illegal EV offer=%v, grid=%v phaseA=%v", got, 6000+got, phaseA+got/(3*230))
}
if svc.Latest() != nil {
t.Fatal("dispatch promoted live reserve into an accepted plan")
}
// The same saved goal now reaches its target; a later tick must release it.
if !mgr.SetCurrentSoC(cfg.ID, .8) {
t.Fatal("could not reach goal")
}
controller.TickWithDispatch(ctx, now.Add(5*time.Second), true)
if commands[len(commands)-1] != 0 {
t.Fatalf("reached goal kept charging: %v", commands)
}
state, ok := mgr.State(cfg.ID)
if !ok || state.TargetSoC != .8 || !state.TargetTime.Equal(deadline) {
t.Fatalf("controller erased durable goal intent: %+v", state)
}
t.Logf("no accepted plan, no battery: legal EV offer %.0fW at grid %.0fW, then0W at goal", got, 6000+got)
}

func TestJoinedMixedPhaseNoCreditBeforeMeasuredReduction(t *testing.T) {
ctx := context.Background()
wall := time.Now()
cfgA := loadpoint.Config{ID: "early", DriverName: "charger-1p", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 1380, MaxChargeW: 5750, PhaseMode: "1p", AllowedStepsW: []float64{0, 1380, 2300, 3680, 4600, 5750}}
cfgB := loadpoint.Config{ID: "later", DriverName: "charger-3p", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 6900, 8280, 9660, 11000}}
mgr := loadpoint.NewManager()
mgr.Load([]loadpoint.Config{cfgA, cfgB})
for _, cfg := range []loadpoint.Config{cfgA, cfgB} {
mgr.Observe(cfg.ID, true, 0, 0, true)
mgr.SetCurrentSoC(cfg.ID, .3)
}
mgr.SetTarget(cfgA.ID, .8, wall.Add(time.Hour))
mgr.SetTarget(cfgB.ID, .8, wall.Add(3*time.Hour))
svc := &mpc.Service{LoadpointStates: mgr.States}
tel := telemetry.NewStore()
st := NewState(0, 0, "meter")
st.Mode = ModePlannerArbitrage
st.UseEnergyDispatch = true
st.SiteFuseAmps = 25
st.SiteFuseVoltage = 230
st.SiteFusePhases = 3
st.SlotDirective = func(at time.Time) (SlotDirective, bool) {
d, ok := svc.SlotDirectiveAt(at)
return SlotDirectiveFromMPC(d), ok
}
actualA, actualB := 0.0, 11000.0
sends := 0
controller := loadpoint.NewController(mgr,
func(at time.Time) (loadpoint.Directive, bool) {
d, ok := svc.SlotDirectiveAt(at)
return d.LoadpointDirective(), ok
},
func(driver string) (loadpoint.EVSample, bool) {
w := actualA
if driver == cfgB.DriverName {
w = actualB
}
return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: w, PowerAt: time.Now(), SessionWhUnavailable: true}, true
},
func(_ context.Context, driver 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" {
return nil
}
if driver == cfgA.DriverName {
actualA = cmd.PowerW
} else if driver == cfgB.DriverName {
actualB = cmd.PowerW
}
sends++
worst := actualA/230 + actualB/690
if worst > 24.001 {
t.Errorf("command%d: phase overage before sibling reduction is measured: A=%v B=%v L1=%.3fA", sends, actualA, actualB, worst)
}
return nil
})
controller.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3})
var sampledWorst float64
controller.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return sampledWorst, actualB / 690, actualB / 690, true })
controller.SetFuseEVMax(func() (float64, bool) { return EVFuseBudget(tel, st, mgr.States(), mgr.Configs(), 17250) })
emit := func(driver string, kind telemetry.DerType, at time.Time, w float64, amps bool) {
d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_available": true}
if amps {
d["l1_a"] = actualA/230 + actualB/690
d["l2_a"] = actualB / 690
d["l3_a"] = actualB / 690
}
b, _ := json.Marshal(d)
tel.Update(driver, kind, w, nil, b)
tel.RecordDriverSuccess(driver)
}
for step := 0; step < 6; step++ {
// Both source series bracket a real site sample. Commands are then applied
// in configured order, so A can rise before B's reduction has taken effect.
at := wall.Add(-9*time.Second + time.Duration(step)*1500*time.Millisecond)
emit(cfgA.DriverName, telemetry.DerEV, at, actualA, false)
emit(cfgB.DriverName, telemetry.DerEV, at, actualB, false)
sampledWorst = actualA/230 + actualB/690
emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), actualA+actualB, true)
emit(cfgA.DriverName, telemetry.DerEV, at.Add(time.Second), actualA, false)
emit(cfgB.DriverName, telemetry.DerEV, at.Add(time.Second), actualB, false)
_ = ComputeDispatch(tel, st, nil, 17250)
controller.TickWithDispatch(ctx, time.Now(), true)
}
if actualA <= 0 || actualB >= 11000 {
t.Fatalf("earlier goal never gained safe measured headroom: A=%v B=%v", actualA, actualB)
}
t.Logf("%d real sends across6 ticks: final1p %.0fW +3p %.0fW, worst phase%.2fA", sends, actualA, actualB, actualA/230+actualB/690)
}
Loading
Loading