diff --git a/.changeset/ev-deadline-continuity.md b/.changeset/ev-deadline-continuity.md new file mode 100644 index 00000000..31564046 --- /dev/null +++ b/.changeset/ev-deadline-continuity.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 42c0a42e..922a4ef3 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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 } @@ -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() @@ -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()), }) @@ -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 @@ -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 diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index cbdd90fa..695d4d0f 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -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 @@ -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 @@ -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)") @@ -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 @@ -1484,6 +1490,7 @@ func ComputeDispatch( } } if len(onlineBats) == 0 { + publishBatterylessEVFuseBudget(store, state, fuseMaxW) state.LastTargets = nil return nil } @@ -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 diff --git a/go/internal/control/ev_deadline_runtime_test.go b/go/internal/control/ev_deadline_runtime_test.go new file mode 100644 index 00000000..ff2950f0 --- /dev/null +++ b/go/internal/control/ev_deadline_runtime_test.go @@ -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) +} diff --git a/go/internal/control/ev_fuse_async_test.go b/go/internal/control/ev_fuse_async_test.go new file mode 100644 index 00000000..c01989ba --- /dev/null +++ b/go/internal/control/ev_fuse_async_test.go @@ -0,0 +1,110 @@ +package control + +import ( + "encoding/json" + "sort" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type externalAuditRawEvent struct { + kind telemetry.DerType + secondsAgo int + watts float64 + confirmed bool +} + +func TestAsynchronousPowerCredit(t *testing.T) { + cases := []struct { + name string + events []externalAuditRawEvent + minCap, maxCap float64 + }{ + {"steady older EV and newer site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerEV, 4, 10000, false}, {telemetry.DerMeter, 3, 13000, false}}, 10000, 14250}, + {"steady newer EV and older site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerMeter, 5, 13000, false}, {telemetry.DerEV, 4, 10000, false}}, 10000, 14250}, + {"new EV rise cannot be subtracted from older site", []externalAuditRawEvent{{telemetry.DerEV, 8, 0, false}, {telemetry.DerMeter, 7, 1000, false}, {telemetry.DerEV, 4, 11000, false}}, 0, 16250}, + {"old high EV then observed stop", []externalAuditRawEvent{{telemetry.DerEV, 8, 11000, false}, {telemetry.DerMeter, 7, 12000, false}, {telemetry.DerEV, 4, 0, false}, {telemetry.DerMeter, 3, 1000, false}}, 0, 16250}, + {"qualified EV permits trimming a small total overload", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 17750, false}, {telemetry.DerEV, 4, 10000, false}}, 1380, 9500}, + {"fresh actual overload beats an older stable bracket", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerMeter, 7, 13000, false}, {telemetry.DerEV, 4, 10000, false}, {telemetry.DerMeter, 3, 30000, false}}, 0, 0}, + {"confirmed unchanged EV supports normal asynchronous site", []externalAuditRawEvent{{telemetry.DerEV, 8, 10000, false}, {telemetry.DerEV, 4, 10000, true}, {telemetry.DerMeter, 3, 13000, false}}, 10000, 14250}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + now := time.Now() + events := append([]externalAuditRawEvent(nil), tc.events...) + sort.SliceStable(events, func(i, j int) bool { return events[i].secondsAgo > events[j].secondsAgo }) + store := telemetry.NewStore() + for _, e := range events { + driver := "meter" + if e.kind == telemetry.DerEV { + driver = "car" + } + data, _ := json.Marshal(map[string]any{"control_power_observed_at": now.Add(-time.Duration(e.secondsAgo) * time.Second).Format(time.RFC3339Nano), "control_power_available": true, "control_power_confirmed": e.confirmed}) + store.Update(driver, e.kind, e.watts, nil, data) + store.DriverHealthMut(driver).RecordSuccess() + } + st := NewState(0, 0, "meter") + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) + if !known || capW < tc.minCap-0.001 || capW > tc.maxCap+0.001 { + t.Fatalf("cap=%v known=%v want[%v,%v]", capW, known, tc.minCap, tc.maxCap) + } + }) + } +} + +func TestExistingPhaseOverloadTrimsKnownThreePhaseEV(t *testing.T) { + now := time.Now() + store := telemetry.NewStore() + emit := func(driver string, kind telemetry.DerType, at time.Time, w float64, amps bool) { + data := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano), "control_power_available": true} + if amps { + data["l1_a"] = 26.0 + data["l2_a"] = 5.0 + data["l3_a"] = 5.0 + } + b, _ := json.Marshal(data) + store.Update(driver, kind, w, nil, b) + store.DriverHealthMut(driver).RecordSuccess() + } + emit("car", telemetry.DerEV, now.Add(-8*time.Second), 10350, false) + emit("meter", telemetry.DerMeter, now.Add(-7*time.Second), 8280, true) + emit("car", telemetry.DerEV, now.Add(-4*time.Second), 10350, false) + st := NewState(0, 0, "meter") + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) + // Current EV is15A on each phase; its underlying L1 net load is11A. + // A24A operating ceiling permits at most13A (=8970W) of EV charge. + if !known || capW <= 0 || capW > 8970.001 { + t.Fatalf("known3p phase overload needs safe positive trim≤8970W, cap=%v known=%v", capW, known) + } +} + +func TestObservedZeroCannotDisappearFromPowerCredit(t *testing.T) { + store := telemetry.NewStore() + at := time.Now().Add(-4 * time.Second).Truncate(time.Second) + emit := func(driver string, kind telemetry.DerType, stamp time.Time, w float64) { + d, _ := json.Marshal(map[string]any{"control_power_observed_at": stamp.Format(time.RFC3339Nano), "control_power_available": true}) + store.Update(driver, kind, w, nil, d) + store.RecordDriverSuccess(driver) + } + emit("car", telemetry.DerEV, at.Add(-100*time.Millisecond), 11000) + emit("car", telemetry.DerEV, at.Add(100*time.Millisecond), 0) + emit("meter", telemetry.DerMeter, at.Add(500*time.Millisecond), 1000) + emit("car", telemetry.DerEV, at.Add(900*time.Millisecond), 11000) + st := NewState(0, 0, "meter") + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + capW, known := EVFuseBudget(store, st, []loadpoint.State{{ID: "a", DriverName: "car", PluggedIn: true}}, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) + if !known || capW > 16250.001 { + t.Fatalf("observed zero erased around site1kW: cap=%v known=%v, must≤16250W", capW, known) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go new file mode 100644 index 00000000..7a80aee4 --- /dev/null +++ b/go/internal/control/ev_fuse_budget.go @@ -0,0 +1,184 @@ +package control + +import ( + "encoding/json" + "math" + "sort" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// evHeadroomW is the shared grid balance for an EV pool. Power outside the +// pool stays in gridW. No unmeasured future battery relief earns headroom. +func evHeadroomW(ceiling, gridW, poolW float64) float64 { + if !finiteFuseValue(ceiling) || !finiteFuseValue(gridW) || !finiteFuseValue(poolW) || ceiling <= 0 || poolW < 0 { + return 0 + } + return math.Max(0, ceiling-gridW+poolW) +} + +func finiteFuseValue(w float64) bool { return !math.IsNaN(w) && !math.IsInf(w, 0) } + +// EVFuseBudget refreshes the common EV cap before a charger command, also +// without batteries. The shared freshness gate must already allow dispatch. +// Other EVs remain measured household demand. Callers hold the control lock. +func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) { + if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 { + return 0, true + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if meter == nil || !finiteFuseValue(meter.RawW) { + return 0, true + } + gridW, poolW := coherentEVBalance(store, state.SiteMeterDriver, meter, points, state.now()) + cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), gridW, poolW) + if phaseW, known := evPhaseIncreaseW(meter, state, configs, poolW); known { + cap = math.Min(cap, math.Max(0, poolW+phaseW)) + } + if state.FuseSaturated { + cap = math.Min(cap, state.FuseEVMaxW) + } + return cap, true +} + +// A managed EV earns replacement credit only across a site observation that +// its raw power brackets. This keeps normal asynchronous polls useful while +// preventing a later start or an earlier stop from inventing spare capacity. +// The existing bounded telemetry ring owns the samples; no second cache or +// filtered load estimate participates in the hard limit. +func coherentEVBalance(store *telemetry.Store, meterDriver string, meter *telemetry.DerReading, points []loadpoint.State, now time.Time) (gridW, poolW float64) { + gridW = meter.RawW + from := now.Add(-telemetry.ControlWindowDuration) + windows := store.ControlWindows(from, now) + sites := windows[meterDriver+":"+telemetry.DerMeter.String()].Points + if current, ok := qualifiedFuseObservation(meter, now); ok { + current.At = current.Seen() + sites = append(sites, current) + } + type evSeries struct { + latest float64 + points []telemetry.ControlObservation + } + var series []evSeries + seen := map[string]bool{} + for _, point := range points { + if !point.PluggedIn || seen[point.DriverName] { + continue + } + seen[point.DriverName] = true + r := store.Get(point.DriverName, telemetry.DerEV) + h := store.DriverHealth(point.DriverName) + current, ok := qualifiedFuseObservation(r, now) + if !ok || h == nil || !h.TelemetryLive() || current.PowerW <= 0 { + continue // Unknown EV power stays inside the measured site demand. + } + ps := windows[point.DriverName+":"+telemetry.DerEV.String()].Points + ps = append(ps, current) // Includes explicit change-only confirmations. + series = append(series, evSeries{math.Min(current.PowerW, r.RawW), ps}) + } + sort.SliceStable(sites, func(i, j int) bool { return sites[i].At.After(sites[j].At) }) + for _, site := range sites { + if site.At.Before(from) || site.At.After(now) || !finiteFuseValue(site.PowerW) { + continue + } + credit, coherent := 0.0, true + for _, ev := range series { + w, ok := bracketedEVPower(ev.points, site.At, ev.latest) + if !ok { + coherent = false + break + } + credit += w + } + if coherent { + // A fresh increase in house/site demand always wins over an + // older coherent sample. A fresh EV drop already reduced credit. + return math.Max(gridW, site.PowerW), credit + } + } + return gridW, 0 +} + +func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry.ControlObservation, bool) { + if r == nil || !finiteFuseValue(r.RawW) || r.UpdatedAt.After(now) || now.Sub(r.UpdatedAt) > telemetry.ControlPowerMaxAge(r.Data) { + return telemetry.ControlObservation{}, false + } + p, ok := telemetry.ControlPowerObservation(r.RawW, r.Data, r.UpdatedAt) + return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration +} + +func bracketedEVPower(points []telemetry.ControlObservation, at time.Time, latest float64) (float64, bool) { + var before, after telemetry.ControlObservation + hasBefore, hasAfter := false, false + for _, p := range points { + if !finiteFuseValue(p.PowerW) || p.PowerW < 0 { + continue + } + if !p.At.After(at) && (!hasBefore || p.At.After(before.At)) { + before, hasBefore = p, true + } + if !p.Seen().Before(at) && (!hasAfter || p.At.Before(after.At)) { + after, hasAfter = p, true + } + } + if !hasBefore || !hasAfter { + return 0, false + } + return math.Max(0, math.Min(latest, math.Min(before.PowerW, after.PowerW))), true +} + +// Phase-current magnitudes cannot tell us that an exporting phase makes +// room on an importing one. Bound the actual increase on the worst phase. +func evPhaseIncreaseW(meter *telemetry.DerReading, state *State, configs []loadpoint.Config, poolW float64) (float64, bool) { + // DER data uses snake case; separate fields retain absent-vs-zero. + var data struct { + L1 *float64 `json:"l1_a"` + L2 *float64 `json:"l2_a"` + L3 *float64 `json:"l3_a"` + } + if json.Unmarshal(meter.Data, &data) != nil { + return 0, false + } + amps := [...]*float64{data.L1, data.L2, data.L3} + known := data.L1 != nil || data.L2 != nil || data.L3 != nil + if !known { + return 0, false + } + if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { + return 0, true + } + worst := 0.0 + for _, a := range amps[:state.SiteFusePhases] { + if a == nil || !finiteFuseValue(*a) { + return 0, true + } + worst = math.Max(worst, math.Abs(*a)) + } + phases := state.SiteFusePhases + unknownPhase := false + for _, cfg := range configs { + if cfg.PhaseMode == "1p" || cfg.PhaseMode == "auto" { + phases = 1 // No promise about an auto charger's current phase dwell. + unknownPhase = true + } + } + deltaA := state.SiteFuseAmps - 1 - worst + if deltaA < 0 && unknownPhase { + return -poolW, true // Cannot prove which single-phase car relieves it. + } + return deltaA * state.SiteFuseVoltage * float64(phases), true +} + +func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + state.FuseEVMaxW, state.FuseSaturated = 0, false + demand := math.Max(state.EVChargingW, scheduledEVPowerW(state)) + if meter == nil || demand <= 0 { + return + } + cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), math.Max(meter.RawW, meter.SmoothedW), state.EVChargingW) + state.FuseEVMaxW = math.Min(demand, cap) + state.FuseSaturated = demand > cap +} diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go new file mode 100644 index 00000000..5d9bc436 --- /dev/null +++ b/go/internal/control/ev_fuse_budget_test.go @@ -0,0 +1,67 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestLiveFuseBudgetPhysicalBalance(t *testing.T) { + cases := []struct { + name string + previousGrid, grid, oldEV, ev float64 + managed, duplicate, stale bool + phase []float64 + maxCap float64 + }{ + {name: "fresh actual overload after low value", previousGrid: 1000, grid: 20000, maxCap: 0}, + {name: "existing managed EV stays in replacement pool", previousGrid: 10000, grid: 10000, oldEV: 8000, ev: 8000, managed: true, maxCap: 15250}, + {name: "EV stopped after high filtered power", previousGrid: 2000, grid: 2000, oldEV: 8000, ev: 0, managed: true, maxCap: 15250}, + {name: "stale managed EV cannot create headroom", previousGrid: 2000, grid: 2000, oldEV: 8000, ev: 8000, managed: true, stale: true, maxCap: 15250}, + {name: "new EV rise after older site sample", previousGrid: 1000, grid: 1000, oldEV: 0, ev: 11000, managed: true, maxCap: 16250}, + {name: "unmanaged EV remains base demand", previousGrid: 8000, grid: 8000, oldEV: 6000, ev: 6000, maxCap: 9250}, + {name: "duplicate identity adds only once", previousGrid: 10000, grid: 10000, oldEV: 8000, ev: 8000, managed: true, duplicate: true, maxCap: 15250}, + {name: "PV export can supply measured net headroom", previousGrid: -5000, grid: -5000, maxCap: 22250}, + {name: "measured battery relief counted once", previousGrid: -3000, grid: -3000, maxCap: 20250}, + {name: "mixed phase directions under net export", previousGrid: -2300, grid: -2300, phase: []float64{10, 10, 10}, maxCap: 10350}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := telemetry.NewStore() + var data json.RawMessage + if tc.phase != nil { + data, _ = json.Marshal(map[string]float64{"l1_a": tc.phase[0], "l2_a": tc.phase[1], "l3_a": tc.phase[2]}) + } + store.Update("meter", telemetry.DerMeter, tc.previousGrid, nil, data) + store.Update("meter", telemetry.DerMeter, tc.grid, nil, data) + store.DriverHealthMut("meter").RecordSuccess() + store.Update("car", telemetry.DerEV, tc.oldEV, nil, nil) + store.Update("car", telemetry.DerEV, tc.ev, nil, nil) + store.DriverHealthMut("car").RecordSuccess() + if tc.stale { + store.Get("car", telemetry.DerEV).UpdatedAt = time.Now().Add(-time.Hour) + } + st := NewState(0, 0, "meter") + st.SiteFuseAmps = 25 + st.SiteFuseVoltage = 230 + st.SiteFusePhases = 3 + var points []loadpoint.State + if tc.managed { + points = append(points, loadpoint.State{ID: "a", DriverName: "car", PluggedIn: true}) + } + if tc.duplicate { + points = append(points, loadpoint.State{ID: "b", DriverName: "car", PluggedIn: true}) + } + got, known := EVFuseBudget(store, st, points, []loadpoint.Config{{ID: "a", DriverName: "car", PhaseMode: "3p"}}, 17250) + if !known || got < 0 || got > tc.maxCap+0.001 { + t.Fatalf("got cap=%v known=%v max=%v; rawgrid=%v smoothgrid=%v rawev=%v smoothev=%v", got, known, tc.maxCap, store.Get("meter", telemetry.DerMeter).RawW, store.Get("meter", telemetry.DerMeter).SmoothedW, store.Get("car", telemetry.DerEV).RawW, store.Get("car", telemetry.DerEV).SmoothedW) + } + if tc.maxCap > 0 && !tc.stale && tc.name != "mixed phase directions under net export" && got == 0 { + t.Fatalf("usable physical budget abandoned: max=%v", tc.maxCap) + } + }) + } +} diff --git a/go/internal/control/ev_plan_priority.go b/go/internal/control/ev_plan_priority.go index 61803931..2889e0e1 100644 --- a/go/internal/control/ev_plan_priority.go +++ b/go/internal/control/ev_plan_priority.go @@ -8,28 +8,40 @@ import "math" // sharing must reserve that on-power, not Wh/slot, or the battery can take // headroom the charger is about to use. func scheduledEVPowerW(state *State) float64 { - if state == nil || !state.Mode.IsPlannerMode() { + if state == nil { return 0 } + commanded := state.EVCommandedW + if math.IsNaN(commanded) || math.IsInf(commanded, 0) || commanded < 0 { + commanded = 0 + } + if !state.Mode.IsPlannerMode() { + return commanded + } dir, ok := planDirectiveForIntent(state) + // EV reserve intent remains available while the battery controller + // honestly follows its missing-plan self-consumption path. + if !ok && state.controlPlanSnapshot.directiveOK && state.controlPlanSnapshot.directive.EVOnly { + dir, ok = state.controlPlanSnapshot.directive, true + } if !ok || state.now().Before(dir.SlotStart) || !state.now().Before(dir.SlotEnd) { - return 0 + return commanded } hours := dir.SlotEnd.Sub(dir.SlotStart).Hours() if hours <= 0 { - return 0 + return commanded } var reserved float64 for id, wh := range dir.LoadpointEnergyWh { if math.IsNaN(wh) || math.IsInf(wh, 0) || wh < 0 { - return 0 + return commanded } if wh <= 0 { continue } if peak, ok := dir.LoadpointMaxPowerW[id]; ok { if math.IsNaN(peak) || math.IsInf(peak, 0) || peak < 0 { - return 0 + return commanded } if peak > 0 { reserved += peak @@ -38,5 +50,5 @@ func scheduledEVPowerW(state *State) float64 { } reserved += wh / hours } - return reserved + return math.Max(commanded, reserved) } diff --git a/go/internal/control/ev_plan_priority_test.go b/go/internal/control/ev_plan_priority_test.go index b8e2192b..bdcebc7c 100644 --- a/go/internal/control/ev_plan_priority_test.go +++ b/go/internal/control/ev_plan_priority_test.go @@ -101,3 +101,44 @@ func TestScheduledEVFallsBackToMeanWithoutOnPower(t *testing.T) { t.Fatalf("mean reservation = %.0f W, want 8000", got) } } + +func TestChargerOfferReservesFuseBeforeOptionalBatteryCharge(t *testing.T) { + for _, mode := range []Mode{ModeSelfConsumption, ModePlannerSelf, ModePlannerArbitrage} { + t.Run(string(mode), func(t *testing.T) { + now := time.Now() + dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(time.Minute), EVOnly: true, + LoadpointEnergyWh: map[string]float64{"manual": 11000.0 / 60}, LoadpointMaxPowerW: map[string]float64{"manual": 11000}} + store := seedStore(-6000, []struct { + name string + currentW, soc float64 + }{{"battery", 0, .5}}) + st := newStateWithEnergyDispatch(dir, "ferroamp") + st.Mode = mode + st.EVCommandedW = 11000 // Final manual/reserve offer, still 0 W measured. + if got := scheduledEVPowerW(st); got != 11000 { + t.Fatalf("ramping offer lost in %s: %v", mode, got) + } + targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 5000) + if len(targets) != 1 || targets[0].TargetW > 0.001 || (st.FuseSaturated && st.FuseEVMaxW < 10999) { + t.Fatalf("battery took the EV's offered room: targets=%v evcap=%v", targets, st.FuseEVMaxW) + } + if mode.IsPlannerMode() && !st.PlanStale { + t.Fatal("EV-only directive claimed a fresh battery plan") + } + }) + } +} + +func TestBatterylessGoalIntentPublishesJointFuseBudget(t *testing.T) { + now := time.Now() + st := newStateWithEnergyDispatch(SlotDirective{SlotStart: now, SlotEnd: now.Add(time.Minute), EVOnly: true, + LoadpointEnergyWh: map[string]float64{"car": 11000.0 / 60}, LoadpointMaxPowerW: map[string]float64{"car": 11000}}, "ferroamp") + st.EVCommandedW = 11000 + store := seedStore(4000, nil) + if targets := ComputeDispatch(store, st, nil, 10000); len(targets) != 0 { + t.Fatalf("invented a battery: %v", targets) + } + if !st.FuseSaturated || st.FuseEVMaxW > 6000.001 || st.FuseEVMaxW <= 0 { + t.Fatalf("no-battery cap=%v saturated=%v", st.FuseEVMaxW, st.FuseSaturated) + } +} diff --git a/go/internal/control/slot_directive.go b/go/internal/control/slot_directive.go index c29f8b15..6f6bced2 100644 --- a/go/internal/control/slot_directive.go +++ b/go/internal/control/slot_directive.go @@ -26,5 +26,6 @@ func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective { LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, LoadpointEnergyWh: maps.Clone(d.LoadpointEnergyWh), LoadpointMaxPowerW: maps.Clone(d.LoadpointMaxPowerW), + EVOnly: d.EVOnly, } } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 1c2915d0..f088cb1b 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -53,6 +53,11 @@ type Controller struct { // fusePhaseCapA holds the per-loadpoint per-phase EV current cap carried // between ticks by the reactive per-phase fuse clamp (tickOne goroutine). fusePhaseCapA map[string]float64 + // tickFuseCaps belongs to the serial tick, after one shared budget read. + tickFuseCaps map[string]float64 + tickDirectiveActive bool + tickDirectiveOK bool + tickDirective Directive // siteSurplusForEVW is live PV left after house load and battery soak. // A false reading pauses surplus_only rather than guessing an import. @@ -150,12 +155,6 @@ type Controller struct { // the feature entirely — the LP behaves exactly as today. batSoC func() (float64, bool) - // gridDeferredMu guards loadpoints whose deadline sits past published - // prices. Those ticks follow surplus, so a sunny plan cannot import - // after the forecast misses. - gridDeferredMu sync.Mutex - gridDeferred map[string]bool - // batSoCArmed is the surplus-unlock hysteresis. batSoCNoPV counts // ticks with no live PV so a full battery does not kick the EV // through the night. @@ -303,6 +302,7 @@ type Directive struct { SlotEnd time.Time LoadpointEnergyWh map[string]float64 LoadpointMaxPowerW map[string]float64 + LoadpointFallback map[string]bool } // EVSample is the loadpoint-relevant slice of telemetry.DerReading @@ -672,7 +672,7 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want if c.fuseEVMax == nil { return wantW, "" } - cap, ok := c.fuseEVMax() + cap, ok := c.fuseCapFor(lpCfg.ID) if !ok || cap < 0 { return wantW, "" } @@ -782,50 +782,12 @@ func (c *Controller) evalBatSoCArm(lpID string, threshold float64) bool { return armed } -// SetGridDeferred records that MPC has suppressed grid-funded EV -// planning for this LP (because the deadline lies past published -// prices). Surplus dispatch semantics apply at runtime too: the EV's -// commanded W is snapped to live surplus only, with no grid import, -// regardless of what the cached MPC plan budget says. Cleared when -// MPC's next replan finds prices for the deadline window. Safe to -// call concurrently with the dispatch tick. -func (c *Controller) SetGridDeferred(lpID string, deferred bool) { - if c == nil { - return - } - c.gridDeferredMu.Lock() - defer c.gridDeferredMu.Unlock() - if c.gridDeferred == nil { - c.gridDeferred = map[string]bool{} - } - if deferred { - c.gridDeferred[lpID] = true - } else { - delete(c.gridDeferred, lpID) - } -} - -// GridDeferred reports whether MPC has deferred grid-funded planning -// for this loadpoint (target deadline past the published price -// horizon). Read by the API layer so the deferral is visible to the -// operator instead of looking like a PV-only mode nobody chose. -func (c *Controller) GridDeferred(lpID string) bool { - return c.gridDeferredFor(lpID) -} - -// gridDeferredFor reads the per-LP deferral flag set by main.go's MPC -// spec builder. Read-only accessor used inside surplusActive. -func (c *Controller) gridDeferredFor(lpID string) bool { - if c == nil { - return false - } - c.gridDeferredMu.Lock() - defer c.gridDeferredMu.Unlock() - return c.gridDeferred[lpID] -} +// GridDeferred remains for clients that read the existing field. Saved +// deadlines no longer defer grid charging solely for unpublished prices. +func (c *Controller) GridDeferred(string) bool { return false } // surplusActive is true when live surplus replaces the plan: SurplusOnly, -// a grid-deferred loadpoint, or an armed bat-SoC unlock. A schedule +// or an armed bat-SoC unlock. A schedule // target is a floor instead. See surplusAddsToPlan. func (c *Controller) surplusActive(lpCfg Config, sched Schedule) bool { if lpCfg.SurplusOnly { @@ -835,8 +797,8 @@ func (c *Controller) surplusActive(lpCfg Config, sched Schedule) bool { if sched.HasTarget() { return false } - if c.gridDeferredFor(lpCfg.ID) { - return true + if c.tickDirectiveActive && c.tickDirectiveOK && c.tickDirective.LoadpointFallback[lpCfg.ID] { + return false // A one-off saved deadline has the same grid floor. } return c.evalBatSoCArm(lpCfg.ID, sched.SurplusUnlockBatSoC) } @@ -861,7 +823,7 @@ func (c *Controller) surplusAddsToPlan(lpCfg Config, sched Schedule) bool { // AnyLoadpointSurplusActive reports whether any configured loadpoint // is currently treating PV surplus as priority — via the configured -// SurplusOnly flag, the MPC grid-deferral flag, or a runtime-armed +// SurplusOnly flag or a runtime-armed // bat-SoC unlock. main.go's siteSurplusForEVW reader uses this to // zero out the home-battery's PV-charge contribution from the EV's // apparent surplus, which prevents the EV from stealing PV that the @@ -876,9 +838,6 @@ func (c *Controller) AnyLoadpointSurplusActive() bool { if cfg.SurplusOnly { return true } - if c.gridDeferredFor(cfg.ID) { - return true - } sched, _ := c.manager.GetSchedule(cfg.ID) if sched.SurplusUnlockBatSoC > 0 { c.batSoCArmedMu.Lock() @@ -1285,12 +1244,14 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat sample EVSample } var observations []observation + samples := map[string]EVSample{} for _, cfg := range c.manager.Configs() { sample, observed := c.tel(cfg.DriverName) if !observed { continue } observations = append(observations, observation{cfg, sample}) + samples[cfg.DriverName] = sample // Only a stale site meter stands chargers down. An old charger power // reading from a live driver is not a hardware risk: the fuse clamps // work from the site meter, and energy accounting already treats the @@ -1304,6 +1265,12 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if c.plan == nil && dispatchAllowed { return } + if dispatchAllowed { + c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) + c.tickDirectiveActive = true + c.freezeFuseBudget(now, samples) + defer func() { c.tickFuseCaps = nil; c.tickDirectiveActive = false }() + } for _, observed := range observations { c.tickOne(ctx, now, observed.config, observed.sample, dispatchAllowed) } @@ -1531,6 +1498,14 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s } else { cmdW, planReady, fuseCapped := c.computeCommand(now, lpCfg, sample.PowerW) cmdReason = "plan" + if c.plan != nil { + if d, ok := c.currentDirective(now); ok && d.LoadpointFallback[lpCfg.ID] { + cmdReason = "EV deadline reserve" + if sample.PowerUnavailable { + cmdW = 0 + } + } + } if fuseCapped { cmdReason = "fuse_limit" } @@ -1584,6 +1559,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdReason = "vehicle_complete" } } + if d, ok := c.currentDirective(now); ok && d.LoadpointFallback[lpCfg.ID] && sample.PowerUnavailable { + cmdW = 0 + } // Fuse protection: applied LAST (after MPC budget, surplus // clamp, wake-kick) so all upstream sources see their nominal // wantW; only the actual ceiling we send to the wallbox is @@ -2430,10 +2408,14 @@ func (c *Controller) setSurplusStepW(id string, w float64) { // commanded reason so a fuse-starved 0 W never reads as "the plan // chose 0" (#1009). func (c *Controller) computeCommand(now time.Time, lpCfg Config, currentPowerW float64) (float64, bool, bool) { + return c.computeCommandForBudget(now, lpCfg, currentPowerW, true) +} + +func (c *Controller) computeCommandForBudget(now time.Time, lpCfg Config, currentPowerW float64, clampFuse bool) (float64, bool, bool) { if c.plan == nil { return 0, false, false } - d, ok := c.plan(now) + d, ok := c.currentDirective(now) if !ok { return 0, false, false } @@ -2441,8 +2423,25 @@ func (c *Controller) computeCommand(now time.Time, lpCfg Config, currentPowerW f if !hasBudget { return 0, false, false } + if d.LoadpointFallback[lpCfg.ID] && c.fuseEVMax == nil { + return 0, true, false + } + if d.LoadpointFallback[lpCfg.ID] && clampFuse { + if cap, known := c.fuseCapFor(lpCfg.ID); !known || !finite(cap) || cap < 0 { + return 0, true, false + } + } + if d.LoadpointFallback[lpCfg.ID] { + st, known := c.manager.State(lpCfg.ID) + if _, _, needed := DeadlineNeed(st); !known || !needed { + return 0, true, false + } + } remainingS := d.SlotEnd.Sub(now).Seconds() alreadyWh, unmeasuredS := c.energySince(lpCfg.ID, d.SlotStart, now) + if d.LoadpointFallback[lpCfg.ID] { + alreadyWh, unmeasuredS = 0, 0 // Fresh goal energy already includes delivery. + } if durationS := d.SlotEnd.Sub(d.SlotStart).Seconds(); durationS > 0 { alreadyWh += budgetWh * unmeasuredS / durationS } @@ -2461,8 +2460,8 @@ func (c *Controller) computeCommand(now time.Time, lpCfg Config, currentPowerW f // would together bust the fuse. Honour it before snapping to the // charger's discrete steps so the snap chooses a level under the cap. fuseCapped := false - if c.fuseEVMax != nil { - if cap, ok := c.fuseEVMax(); ok && cap >= 0 && wantW > cap { + if clampFuse && c.fuseEVMax != nil { + if cap, ok := c.fuseCapFor(lpCfg.ID); ok && cap >= 0 && wantW > cap { wantW = cap fuseCapped = true } diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go new file mode 100644 index 00000000..148a7f15 --- /dev/null +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -0,0 +1,150 @@ +package loadpoint + +import ( + "math" + "sort" + "time" +) + +type fuseRequest struct { + cfg Config + deadline time.Time + wantW float64 + liveW float64 + goal bool +} + +// freezeFuseBudget divides one cap for the whole tick, before any command. +// A later callback or goal update cannot give a second car the first car's +// already offered share. Metered reductions earn new room only next tick. +func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { + c.tickFuseCaps = nil + if c.fuseEVMax == nil { + return + } + total, ok := c.fuseEVMax() + if !ok || total < 0 { + return + } + if !finite(total) { + total = 0 + } + d, _ := c.currentDirective(now) + var requests []fuseRequest + var liveTotal float64 + for _, cfg := range c.manager.Configs() { + sample := samples[cfg.DriverName] + st, _ := c.manager.State(cfg.ID) + st.PluggedIn = sample.Connected && !sample.ConnectionUnknown + request := fuseRequest{cfg: cfg, deadline: st.TargetTime} + if sample.Connected && finite(sample.PowerW) { + request.liveW = math.Max(0, sample.PowerW) + liveTotal += request.liveW + } + if st.PluggedIn && c.driverCanDispatch(cfg.DriverName) { + if hold, held := c.GetManualHold(cfg.ID, now); held { + request.wantW = clampManualPower(cfg, hold, c.siteFuse()) + } else { + request.wantW, _, _ = c.computeCommandForBudget(now, cfg, sample.PowerW, false) + } + request.goal = request.wantW > 0 && !cfg.SurplusOnly + if _, held := c.GetManualHold(cfg.ID, now); !held { + c.batSoCArmedMu.Lock() + armed := c.batSoCArmed[cfg.ID] + c.batSoCArmedMu.Unlock() + if cfg.SurplusOnly || armed || c.wakeKickActive(cfg.ID, now) || (st.FinishAtVehicleLimit && !st.GoalComplete) { + request.wantW = cfg.MaxChargeW + } + if d.LoadpointFallback[cfg.ID] && sample.PowerUnavailable { + request.wantW = 0 + } + } + } + requests = append(requests, request) + } + sort.SliceStable(requests, func(i, j int) bool { + a, b := requests[i], requests[j] + if a.goal != b.goal { + return a.goal // Required charging precedes a speculative PV/wake offer. + } + if a.deadline.IsZero() != b.deadline.IsZero() { + return !a.deadline.IsZero() + } + if !a.deadline.Equal(b.deadline) { + return a.deadline.Before(b.deadline) + } + return a.cfg.ID < b.cfg.ID + }) + c.tickFuseCaps = make(map[string]float64, len(requests)) + remaining, increase := total, math.Max(0, total-liveTotal) + if phaseIncrease, known := c.sharedPhaseIncreaseW(now, requests, liveTotal); known { + if phaseIncrease < 0 { + total = math.Min(total, math.Max(0, liveTotal+phaseIncrease)) + remaining = total + } + increase = math.Max(0, math.Min(increase, phaseIncrease)) + } + for _, request := range requests { + target := floorChargeW(math.Min(request.wantW, remaining), request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) + remaining -= target + // Do not spend another car's curtailment before its meter confirms it. + want := math.Min(target, request.liveW+increase) + cap := floorChargeW(want, request.cfg.MinChargeW, request.cfg.MaxChargeW, request.cfg.AllowedStepsW) + c.tickFuseCaps[request.cfg.ID] = cap + increase -= math.Max(0, cap-request.liveW) + } +} + +// A new single-phase offer can land on the busiest phase. Do not spend a +// different car's still-measured draw to move power between phases. +func (c *Controller) sharedPhaseIncreaseW(now time.Time, requests []fuseRequest, liveTotal float64) (float64, bool) { + if c.perPhaseMeterAmps == nil { + return 0, false + } + l1, l2, l3, ok := c.perPhaseMeterAmps() + site := c.siteFuse() + if !ok || !finite(site.MaxAmps) || !finite(site.Voltage) || site.MaxAmps <= 0 || site.Voltage <= 0 { + return 0, false + } + if !finite(l1) || !finite(l2) || !finite(l3) { + return 0, true + } + phaseCount := site.Phases() + unknownPhase := false + for _, request := range requests { + mode := request.cfg.PhaseMode + if hold, ok := c.GetManualHold(request.cfg.ID, now); ok && hold.PhaseMode != "" { + mode = hold.PhaseMode + } + if mode == "1p" || mode == "auto" { + phaseCount = 1 + unknownPhase = true + } + } + worst := math.Max(math.Abs(l1), math.Max(math.Abs(l2), math.Abs(l3))) + deltaA := site.MaxAmps - fusePhaseMarginA - worst + if deltaA < 0 && unknownPhase { + return -liveTotal, true + } + return deltaA * site.Voltage * float64(phaseCount), true +} + +func (c *Controller) currentDirective(now time.Time) (Directive, bool) { + if c.tickDirectiveActive { + return c.tickDirective, c.tickDirectiveOK + } + if c.plan == nil { + return Directive{}, false + } + return c.plan(now) +} + +func (c *Controller) fuseCapFor(id string) (float64, bool) { + if c.tickFuseCaps != nil { + return c.tickFuseCaps[id], true + } + if c.fuseEVMax != nil { + return c.fuseEVMax() + } + return 0, false +} diff --git a/go/internal/loadpoint/controller_phase_mode_test.go b/go/internal/loadpoint/controller_phase_mode_test.go index f3f24860..577c8dea 100644 --- a/go/internal/loadpoint/controller_phase_mode_test.go +++ b/go/internal/loadpoint/controller_phase_mode_test.go @@ -47,24 +47,17 @@ func TestResolvePhaseMode(t *testing.T) { } } -// TestSurplusActive_ScheduleOverridesRuntimeClamp verifies that a committed -// charge schedule (with surplus_only OFF) disables the RUNTIME surplus clamp -// (the MPC grid-deferral guard), so the schedule's planned grid charge isn't -// throttled to live PV surplus. The explicit SurplusOnly config still wins. -// Operator directive 2026-05-30. -func TestSurplusActive_ScheduleOverridesRuntimeClamp(t *testing.T) { +func TestSurplusActiveKeepsOnlyExplicitSolarPermission(t *testing.T) { c := NewController(NewManager(), nil, nil, nil) - cfg := Config{ID: "lp1"} // SurplusOnly: false - c.SetGridDeferred("lp1", true) - - if !c.surplusActive(cfg, Schedule{}) { - t.Fatal("grid-deferred + no schedule should be surplus-active") + cfg := Config{ID: "lp1"} + if c.surplusActive(cfg, Schedule{}) || c.GridDeferred(cfg.ID) { + t.Fatal("missing prices must not invent a solar-only choice") } - if c.surplusActive(cfg, Schedule{SoC: 0.5}) { - t.Error("active schedule (surplus_only off) must disable the runtime surplus clamp") + if c.surplusActive(cfg, Schedule{SoC: .5}) { + t.Fatal("saved goal must permit its grid budget") } cfg.SurplusOnly = true - if !c.surplusActive(cfg, Schedule{SoC: 0.5}) { - t.Error("explicit SurplusOnly config must stay surplus-active even with a schedule") + if !c.surplusActive(cfg, Schedule{SoC: .5}) { + t.Fatal("explicit solar-only permission must remain") } } diff --git a/go/internal/loadpoint/controller_shared_fuse_test.go b/go/internal/loadpoint/controller_shared_fuse_test.go new file mode 100644 index 00000000..7117e017 --- /dev/null +++ b/go/internal/loadpoint/controller_shared_fuse_test.go @@ -0,0 +1,196 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func TestTwoCarsShareTotalFuseCap(t *testing.T) { + now := time.Now() + configs := []Config{ + {ID: "a", DriverName: "charger-a", MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + {ID: "b", DriverName: "charger-b", MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + } + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"a": 2750, "b": 2750}, LoadpointMaxPowerW: map[string]float64{"a": 11000, "b": 11000}} + sender := &fakeSender{} + c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true}, "charger-b": {Connected: true}}, sender) + c.SetFuseEVMax(func() (float64, bool) { return 5000, true }) + c.Tick(context.Background(), now) + total := 0.0 + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + total += cmd.power + } + } + if len(commands) != 2 { + t.Fatalf("commands=%v", commands) + } + if total > 5000.0001 { + t.Fatalf("shared cap=5000 W, sum=%.2f W, per charger=%v", total, commands) + } + if total <= 0 { + t.Fatalf("usable shared cap abandoned: %v", commands) + } +} + +func TestMixedChargingIntentSharesTotalFuseCap(t *testing.T) { + for _, intent := range []string{"manual-a", "manual-both", "pause-a", "planned-zero-a", "nan-cap", "infinite-cap"} { + t.Run(intent, func(t *testing.T) { + now := time.Now() + configs := []Config{ + {ID: "a", DriverName: "charger-a", MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + {ID: "b", DriverName: "charger-b", MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + } + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"a": 2750, "b": 2750}, LoadpointMaxPowerW: map[string]float64{"a": 11000, "b": 11000}} + sender := &fakeSender{} + c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}}, sender) + capW := 5000.0 + switch intent { + case "manual-a": + c.SetManualHold("a", ManualHold{PowerW: 11000, Persistent: true}) + case "manual-both": + c.SetManualHold("a", ManualHold{PowerW: 11000, Persistent: true}) + c.SetManualHold("b", ManualHold{PowerW: 11000, Persistent: true}) + case "pause-a": + c.SetManualHold("a", ManualHold{PowerW: 0, Persistent: true}) + case "planned-zero-a": + d.LoadpointEnergyWh["a"] = 0 + case "nan-cap": + capW = math.NaN() + case "infinite-cap": + capW = math.Inf(1) + } + c.SetFuseEVMax(func() (float64, bool) { return capW, true }) + c.Tick(context.Background(), now) + total := 0.0 + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + total += cmd.power + } + } + if len(commands) != 2 { + t.Fatalf("commands=%v", commands) + } + maxTotal := 5000.0 + if math.IsNaN(capW) || math.IsInf(capW, 0) { + maxTotal = 0 + } + if math.IsNaN(total) || math.IsInf(total, 0) || total > maxTotal+0.0001 { + t.Fatalf("intent=%s cap=%v sum=%v per charger=%v", intent, capW, total, commands) + } + if (intent == "pause-a" || intent == "planned-zero-a") && commands["charger-a"] != 0 { + t.Fatalf("zero intent replaced: %v", commands) + } + if maxTotal > 0 && total <= 0 { + t.Fatalf("usable cap abandoned: %v", commands) + } + }) + } +} + +func TestOnePhaseCarsShareWorstPhaseBudget(t *testing.T) { + now := time.Now() + configs := []Config{ + {ID: "a", DriverName: "charger-a", MinChargeW: 1380, MaxChargeW: 5750, PhaseMode: "1p", AllowedStepsW: []float64{0, 1380, 2300, 3680, 4600, 5750}}, + {ID: "b", DriverName: "charger-b", MinChargeW: 1380, MaxChargeW: 5750, PhaseMode: "1p", AllowedStepsW: []float64{0, 1380, 2300, 3680, 4600, 5750}}, + } + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"a": 1437.5, "b": 1437.5}, LoadpointMaxPowerW: map[string]float64{"a": 5750, "b": 5750}} + sender := &fakeSender{} + c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}}, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + c.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { return 1000.0 / 230, 1000.0 / 230, 1000.0 / 230, true }) + c.SetFuseEVMax(func() (float64, bool) { return 14250, true }) + c.Tick(context.Background(), now) + total := 0.0 + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + total += cmd.power + } + } + // Both unknown/same single-phase connections may be on L1. The house + // takes1000W there; the offered EV sum must fit the remaining4750W. + if total > 4750.0001 { + t.Fatalf("single-phase sum=%v implies L1=%.2fA above25A, commands=%v", total, (1000+total)/230, commands) + } + if total <= 0 { + t.Fatalf("usable phase headroom abandoned: %v", commands) + } +} + +func TestReachedGoalDoesNotReserveIdleFuseBudget(t *testing.T) { + now := time.Now() + configs := []Config{ + {ID: "a", DriverName: "charger-a", VehicleCapacityWh: 60000, PluginSoC: .8, MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + {ID: "b", DriverName: "charger-b", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + } + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"b": 2750}, LoadpointMaxPowerW: map[string]float64{"b": 11000}} + sender := &fakeSender{} + c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}}, sender) + for _, id := range []string{"a", "b"} { + c.manager.Observe(id, true, 0, 0, true) + } + c.manager.SetTarget("a", .8, now.Add(time.Hour)) + c.manager.SetTarget("b", .8, now.Add(2*time.Hour)) + c.manager.SetCurrentSoC("a", .8) + c.manager.SetCurrentSoC("b", .3) + c.SetFuseEVMax(func() (float64, bool) { return 5000, true }) + c.Tick(context.Background(), now) + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + } + } + if commands["charger-a"] != 0 { + t.Fatalf("reached goal resumed: %v", commands) + } + if commands["charger-b"] <= 0 { + t.Fatalf("reached early goal consumed phantom shared budget: %v", commands) + } + if commands["charger-b"] > 5000.0001 { + t.Fatalf("remaining goal exceeded shared cap: %v", commands) + } +} + +func TestZeroSurplusDoesNotReservePhantomFuseBudget(t *testing.T) { + now := time.Now() + configs := []Config{ + {ID: "a", DriverName: "charger-a", VehicleCapacityWh: 60000, PluginSoC: .3, SurplusOnly: true, MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + {ID: "b", DriverName: "charger-b", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 2760, 4140, 5520, 6900, 8280, 9660, 11000}}, + } + d := &Directive{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"a": 0, "b": 2750}, LoadpointMaxPowerW: map[string]float64{"a": 11000, "b": 11000}} + sender := &fakeSender{} + c := newTestController(t, configs, d, map[string]EVSample{"charger-a": {Connected: true, RequestActive: true}, "charger-b": {Connected: true, RequestActive: true}}, sender) + for _, id := range []string{"a", "b"} { + c.manager.Observe(id, true, 0, 0, true) + c.manager.SetCurrentSoC(id, .3) + } + c.manager.SetTarget("a", .8, now.Add(time.Hour)) + c.manager.SetTarget("b", .8, now.Add(2*time.Hour)) + c.SetSiteSurplusForEV(func() (float64, bool) { return 0, true }) + c.SetFuseEVMax(func() (float64, bool) { return 5000, true }) + c.Tick(context.Background(), now) + commands := map[string]float64{} + for _, cmd := range sender.calls { + if cmd.action == "ev_set_current" { + commands[cmd.driver] = cmd.power + } + } + if commands["charger-a"] != 0 { + t.Fatalf("surplus-only car imported without PV: %v", commands) + } + if commands["charger-b"] <= 0 { + t.Fatalf("zero-PV opportunism consumed real goal's shared budget: %v", commands) + } + if commands["charger-b"] > 5000.0001 { + t.Fatalf("remaining goal exceeded shared cap: %v", commands) + } +} diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go new file mode 100644 index 00000000..57b1cb96 --- /dev/null +++ b/go/internal/loadpoint/deadline_need.go @@ -0,0 +1,53 @@ +package loadpoint + +import ( + "math" +) + +// DeadlineNeed is the remaining AC energy and legal on-power of a saved goal. +// It does not promise future site capacity or authorize a hardware command. +func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { + if !st.PluggedIn || st.ChargingDeclined || st.GoalComplete || st.TargetTime.IsZero() || + !finite(st.CurrentSoC) || st.CurrentSoC < 0 || st.CurrentSoC > 1 || + !finite(st.TargetSoC) || st.TargetSoC < 0 || st.TargetSoC > 1 || + !finite(st.VehicleCapacityWh) || st.VehicleCapacityWh <= 0 || + !finite(st.MinChargeW) || !finite(st.MaxChargeW) || st.MinChargeW <= 0 || st.MaxChargeW < st.MinChargeW { + return 0, 0, false + } + target := st.TargetSoC + if st.FinishAtVehicleLimit { + target = 1 // The controller still stops at the car's own completion. + } + energyWh = (target - st.CurrentSoC) * st.VehicleCapacityWh / DefaultChargeEfficiency + if energyWh <= 0 || !finite(energyWh) { + return 0, 0, false + } + for _, step := range st.AllowedStepsW { + if !finite(step) || step < 0 || step > st.MaxChargeW { + return 0, 0, false + } + if step >= st.MinChargeW { + powerW = math.Max(powerW, step) + } + } + if len(st.AllowedStepsW) == 0 { + powerW = st.MaxChargeW + } + return energyWh, powerW, powerW >= st.MinChargeW +} + +// CommandedEVPowerW uses the actual offers after every charger clamp. A +// charger need not have started drawing for its offer to occupy the fuse. +func CommandedEVPowerW(states []State) float64 { + byDriver := make(map[string]float64, len(states)) + for _, st := range states { + if st.PluggedIn && st.CommandedKnown && finite(st.CommandedW) && st.CommandedW > byDriver[st.DriverName] { + byDriver[st.DriverName] = st.CommandedW + } + } + var total float64 + for _, w := range byDriver { + total += w + } + return total +} diff --git a/go/internal/loadpoint/surplus_reserve.go b/go/internal/loadpoint/surplus_reserve.go index 494c3036..4cd2d7cf 100644 --- a/go/internal/loadpoint/surplus_reserve.go +++ b/go/internal/loadpoint/surplus_reserve.go @@ -120,15 +120,6 @@ func SurplusPotentialW(states []State) float64 { return sum } -// PlannerTreatsLoadpointAsSurplusOnly is the SurplusOnly flag the MPC spec -// should carry. The bat-SoC unlock is a this-tick opportunistic clamp; -// putting it on the 48 h spec forbids night-time grid EV in a plan that -// was computed while the sun was still up. Battery→EV is already blocked -// by NoBatteryToEV. -func PlannerTreatsLoadpointAsSurplusOnly(operatorSurplusOnly, deferGridPlan bool) bool { - return operatorSurplusOnly || deferGridPlan -} - // SurplusAvailableForEVW is the PV leftover the surplus-only clamp may // offer this tick. -gridW + batW + evW is house leftover. The EV tick // runs before battery dispatch, so a battery still soaking PV is not diff --git a/go/internal/loadpoint/surplus_reserve_test.go b/go/internal/loadpoint/surplus_reserve_test.go index dd4cf0b9..df876a14 100644 --- a/go/internal/loadpoint/surplus_reserve_test.go +++ b/go/internal/loadpoint/surplus_reserve_test.go @@ -263,24 +263,6 @@ func TestPlannedPVSoakWIgnoresGridFundedCharge(t *testing.T) { } } -func TestPlannerTreatsLoadpointAsSurplusOnly(t *testing.T) { - if !PlannerTreatsLoadpointAsSurplusOnly(true, false) { - t.Fatal("operator surplus_only") - } - if !PlannerTreatsLoadpointAsSurplusOnly(false, true) { - t.Fatal("deadline past published prices") - } - if PlannerTreatsLoadpointAsSurplusOnly(false, false) { - t.Fatal("plain loadpoint") - } - // Bat-SoC unlock is this-tick only and must not appear here. -} - -// Manual/schedule override: a force-charging (manual hold) surplus_only EV -// must contribute NO reserve — the battery is meant to cover it, and a -// non-zero reserve arms the dispatch no-discharge floor which flaps the -// battery support 0↔full (Stefan 2026-06-11 manual charge). surplus_only -// stays on so automatic surplus charging resumes when the hold ends. func TestSurplusReserveWManualActiveSkipped(t *testing.T) { // Drawing 6.2 kW under a manual hold → would normally reserve // current+headroom; with ManualActive it must reserve 0. diff --git a/go/internal/mpc/deadline_directive.go b/go/internal/mpc/deadline_directive.go new file mode 100644 index 00000000..6d7005a4 --- /dev/null +++ b/go/internal/mpc/deadline_directive.go @@ -0,0 +1,62 @@ +package mpc + +import ( + "maps" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// withDeadlineReserve supplies current EV intent. It never activates or +// invents an optimizer plan, battery energy, a price or a solar forecast. +func (s *Service) withDeadlineReserve(now time.Time, d SlotDirective, accepted bool) (SlotDirective, bool) { + if s.LoadpointStates == nil { + return d, accepted + } + states := s.LoadpointStates() // No control lock: the caller may hold it. + if !accepted { + d = SlotDirective{SlotStart: now, SlotEnd: now.Add(time.Minute), EVOnly: true} + } + for _, st := range states { + if _, hasBudget := d.LoadpointEnergyWh[st.ID]; hasBudget && !st.ManualActive { + continue // An accepted zero budget is a price decision, too. + } + need, peak, ok := loadpoint.DeadlineNeed(st) + if st.ManualActive && st.PluggedIn { + // Manual intent has already passed the charger's own clamps. + // Reserve its ceiling, including an explicit Stop, without + // turning it into a saved scheduled goal. + peak, ok = 0, true + if finite(st.ManualChargeW) && st.ManualChargeW > 0 && finite(st.MaxChargeW) && st.MaxChargeW > 0 { + peak = min(st.ManualChargeW, st.MaxChargeW) + } + need = peak * d.SlotEnd.Sub(now).Hours() + } + if !ok || st.ID == "" || (st.SurplusOnly && !st.ManualActive) { + continue + } + if !st.ManualActive { + // Near the goal use the smallest legal offer that can deliver + // this minute's need, rather than reserving the charger's full + // rating for a final few Wh. + peak = loadpoint.SnapChargeW(need/d.SlotEnd.Sub(now).Hours(), st.MinChargeW, peak, st.AllowedStepsW) + } + if d.LoadpointFallback == nil { + d.LoadpointFallback = map[string]bool{} + d.LoadpointEnergyWh = maps.Clone(d.LoadpointEnergyWh) + d.LoadpointMaxPowerW = maps.Clone(d.LoadpointMaxPowerW) + if d.LoadpointEnergyWh == nil { + d.LoadpointEnergyWh = map[string]float64{} + } + if d.LoadpointMaxPowerW == nil { + d.LoadpointMaxPowerW = map[string]float64{} + } + } + // The state already includes delivery before now. Reserve Wh are + // remaining goal energy, not a second budget from the slot's origin. + d.LoadpointEnergyWh[st.ID] = min(need, peak*d.SlotEnd.Sub(now).Hours()) + d.LoadpointMaxPowerW[st.ID] = peak + d.LoadpointFallback[st.ID] = true + } + return d, accepted || len(d.LoadpointFallback) > 0 +} diff --git a/go/internal/mpc/deadline_directive_test.go b/go/internal/mpc/deadline_directive_test.go new file mode 100644 index 00000000..ccc4c89d --- /dev/null +++ b/go/internal/mpc/deadline_directive_test.go @@ -0,0 +1,117 @@ +package mpc + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func deadlineGoalState(now time.Time, id string) loadpoint.State { + return loadpoint.State{ID: id, DriverName: "charger-" + id, PluggedIn: true, VehicleCapacityWh: 60000, CurrentSoC: .3, TargetSoC: .8, TargetTime: now.Add(3 * time.Hour), MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 4140, 6900, 11000}} +} + +func TestGoalReserveWhenNoAcceptedPlan(t *testing.T) { + now := time.Now() + for _, n := range []int{1, 2} { + t.Run(string(rune('0'+n))+"cars", func(t *testing.T) { + states := []loadpoint.State{deadlineGoalState(now, "a")} + if n == 2 { + states = append(states, deadlineGoalState(now, "b")) + } + for i := range states { + states[i].TargetTime = now.Add(time.Second) + } // impossible goal still expresses usable intent + svc := &Service{LoadpointStates: func() []loadpoint.State { return states }} + d, ok := svc.SlotDirectiveAt(now) + if !ok || !d.EVOnly || d.BatteryEnergyWh != 0 { + t.Fatalf("no usable EV-only reserve: %+v ok=%v", d, ok) + } + if len(d.LoadpointFallback) != n { + t.Fatalf("missing goal: %+v", d) + } + for _, st := range states { + if d.LoadpointEnergyWh[st.ID] <= 0 || d.LoadpointMaxPowerW[st.ID] <= 0 { + t.Fatalf("goal suppressed: %+v", d) + } + } + if svc.Latest() != nil { + t.Fatal("live goal reserve invented accepted plan") + } + }) + } +} + +func TestGoalReserveKeepsKnownZeroAndIndependentCars(t *testing.T) { + now := time.Now() + a, b := deadlineGoalState(now, "a"), deadlineGoalState(now, "b") + states := []loadpoint.State{a, b} + svc := &Service{LoadpointStates: func() []loadpoint.State { return states }} + original := SlotDirective{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"a": 0}, LoadpointMaxPowerW: map[string]float64{"a": 11000}} + d, ok := svc.withDeadlineReserve(now, original, true) + if !ok || d.EVOnly || d.LoadpointEnergyWh["a"] != 0 || d.LoadpointFallback["a"] { + t.Fatalf("accepted cheap-future zero lost: %+v", d) + } + if d.LoadpointEnergyWh["b"] <= 0 || !d.LoadpointFallback["b"] { + t.Fatalf("missing car goal not retained: %+v", d) + } + if _, mutated := original.LoadpointEnergyWh["b"]; mutated { + t.Fatal("shared accepted plan map mutated") + } + states[0].CurrentSoC = math.NaN() + d, ok = svc.SlotDirectiveAt(now) + if !ok || d.LoadpointEnergyWh["b"] <= 0 { + t.Fatalf("one bad car erased good car: %+v", d) + } + if _, bad := d.LoadpointEnergyWh["a"]; bad { + t.Fatalf("NaN car got invented intent: %+v", d) + } +} + +func TestGoalReachedPauseAndMissingState(t *testing.T) { + now := time.Now() + tests := []struct { + name string + change func(*loadpoint.State) + wantZeroIntent bool + }{ + {"goal reached", func(s *loadpoint.State) { s.CurrentSoC = .8 }, false}, + {"unplugged", func(s *loadpoint.State) { s.PluggedIn = false }, false}, + {"missing capacity", func(s *loadpoint.State) { s.VehicleCapacityWh = 0 }, false}, + {"NaN capacity", func(s *loadpoint.State) { s.VehicleCapacityWh = math.NaN() }, false}, + {"infinite max", func(s *loadpoint.State) { s.MaxChargeW = math.Inf(1) }, false}, + {"bad step", func(s *loadpoint.State) { s.AllowedStepsW = []float64{0, math.NaN(), 11000} }, false}, + {"no goal deadline", func(s *loadpoint.State) { s.TargetTime = time.Time{} }, false}, + {"surplus only", func(s *loadpoint.State) { s.SurplusOnly = true }, false}, + {"manual pause", func(s *loadpoint.State) { s.ManualActive = true; s.ManualChargeW = 0 }, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + st := deadlineGoalState(now, "a") + tc.change(&st) + svc := &Service{LoadpointStates: func() []loadpoint.State { return []loadpoint.State{st} }} + d, ok := svc.SlotDirectiveAt(now) + if tc.wantZeroIntent { + if !ok || d.LoadpointEnergyWh["a"] != 0 || d.LoadpointMaxPowerW["a"] != 0 { + t.Fatalf("manual pause lost: %+v ok=%v", d, ok) + } + } else if ok { + t.Fatalf("invalid/completed state invented reserve: %+v", d) + } + }) + } +} + +func TestDeadlineReservePreservesAcceptedLegacyZero(t *testing.T) { + now := time.Now() + st := deadlineGoalState(now, "a") + svc := &Service{LoadpointStates: func() []loadpoint.State { return []loadpoint.State{st} }} + svc.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Mode: ModeArbitrage, + Actions: []Action{{SlotStartMs: now.UnixMilli(), SlotLenMin: 15, LoadpointW: 0}}}, Params{Mode: ModeArbitrage}, st.ID) + d, ok := svc.SlotDirectiveAt(now) + wh, hasBudget := d.LoadpointEnergyWh[st.ID] + if !ok || !hasBudget || wh != 0 || d.LoadpointFallback[st.ID] || d.EVOnly { + t.Fatalf("known cheap-future zero turned into a charge reserve: %+v", d) + } +} diff --git a/go/internal/mpc/deadline_runtime_test.go b/go/internal/mpc/deadline_runtime_test.go new file mode 100644 index 00000000..20d901a9 --- /dev/null +++ b/go/internal/mpc/deadline_runtime_test.go @@ -0,0 +1,160 @@ +package mpc + +import ( + "context" + "encoding/json" + "math" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Run the real charger translator from the same service callback used by +// battery dispatch. No optimizer success, price or battery is fabricated. +func TestDeadlineRuntimeContinuesThroughMissingPlan(t *testing.T) { + for _, fault := range []string{"no plan", "battery unavailable before goal provider", "prices unavailable", "failed replacement", "expired plan", "goal reached", "site stale", "manual stop", "power unavailable", "no safe cap", "unknown safe cap"} { + t.Run(fault, func(t *testing.T) { + now := time.Now() + mgr := loadpoint.NewManager() + cfg := loadpoint.Config{ID: "a", DriverName: "charger-a", VehicleCapacityWh: 60000, PluginSoC: .3, + MinChargeW: 1380, MaxChargeW: 11000, AllowedStepsW: []float64{0, 1380, 4140, 6900, 11000}} + mgr.Load([]loadpoint.Config{cfg}) + mgr.Observe(cfg.ID, true, 0, 0, true) + mgr.SetCurrentSoC(cfg.ID, .3) + mgr.SetTarget(cfg.ID, .8, now.Add(time.Second)) // Deliberately impossible. + svc := shadowTestService(t) + svc.LoadpointStates = mgr.States + t.Cleanup(func() { svc.replanWG.Wait(); svc.shadowWG.Wait() }) + var providerCalled bool + svc.Loadpoints = func(int) []*LoadpointSpec { providerCalled = true; return nil } + switch fault { + case "battery unavailable before goal provider": + svc.Tele = telemetry.NewStore() + svc.BatteryFleet = []BatteryFleetMember{{Driver: "missing", CapacityWh: 10000, MaxChargeW: 2000, MaxDischargeW: 2000}} + if svc.Replan(context.Background()) != nil || providerCalled { + t.Fatal("fixture did not fail before the optimizer's goal provider") + } + case "prices unavailable": + svc.Zone = "no_prices" + if svc.Replan(context.Background()) != nil { + t.Fatal("fixture unexpectedly planned without prices") + } + case "failed replacement", "expired plan": + generated := now + if fault == "expired plan" { + generated = now.Add(-2 * MaxPlanAge) + } + svc.InstallPlan(Plan{GeneratedAtMs: generated.UnixMilli(), Actions: []Action{{SlotStartMs: now.UnixMilli(), SlotLenMin: 15}}}, Params{}, cfg.ID) + if fault == "failed replacement" { + svc.failedReplanGeneration = svc.publishedReplanGeneration + 1 + } + case "goal reached": + mgr.SetCurrentSoC(cfg.ID, .8) + } + sample := loadpoint.EVSample{Connected: true, RequestActive: true, PowerUnavailable: fault == "power unavailable"} + var commanded float64 = -1 + ctrl := 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 sample, true }, func(_ context.Context, _ string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if cmd.Action == "ev_set_current" { + commanded = cmd.PowerW + } + return nil + }) + if fault != "no safe cap" { + ctrl.SetFuseEVMax(func() (float64, bool) { return 11000, fault != "unknown safe cap" }) + } + if fault == "manual stop" { + ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 0, Persistent: true}) + } + ctrl.TickWithDispatch(context.Background(), now, fault != "site stale") + wantCharge := fault != "goal reached" && fault != "site stale" && fault != "manual stop" && fault != "power unavailable" && fault != "no safe cap" && fault != "unknown safe cap" + if (commanded > 0) != wantCharge || commanded < 0 || commanded > cfg.MaxChargeW { + t.Fatalf("fault=%s commanded=%v wantCharge=%v", fault, commanded, wantCharge) + } + if wantCharge { + st, _ := mgr.State(cfg.ID) + if st.CommandedReason != "EV deadline reserve" { + t.Fatalf("misleading reason: %s", st.CommandedReason) + } + } + if fault != "failed replacement" && fault != "expired plan" && svc.Latest() != nil { + t.Fatal("runtime reserve was published as an optimizer plan") + } + }) + } +} + +func TestDeadlineReserveSmallRemainderLeavesRoomForOtherGoal(t *testing.T) { + now := time.Now() + a, b := deadlineGoalState(now, "a"), deadlineGoalState(now, "b") + a.CurrentSoC = a.TargetSoC - loadpoint.DefaultChargeEfficiency/a.VehicleCapacityWh // 1 AC Wh. + svc := &Service{LoadpointStates: func() []loadpoint.State { return []loadpoint.State{a, b} }} + d, ok := svc.SlotDirectiveAt(now) + if !ok || math.Abs(d.LoadpointEnergyWh["a"]-1) > 1e-6 || d.LoadpointMaxPowerW["a"] != a.MinChargeW || d.LoadpointMaxPowerW["b"] != b.MaxChargeW { + t.Fatalf("small remainder took the whole fuse: %+v", d) + } +} + +func TestDeadlineGoalSnapshotLockOrder(t *testing.T) { + now := time.Now() + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "a", DriverName: "charger-a", VehicleCapacityWh: 60000, PluginSoC: .3, MinChargeW: 1380, MaxChargeW: 11000}}) + mgr.Observe("a", true, 0, 0, true) + mgr.SetTarget("a", .8, now.Add(time.Hour)) + var controlMu sync.Mutex + svc := &Service{} + ctrl := 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}, true + }, nil) + svc.LoadpointStates = func() []loadpoint.State { + states := mgr.States() + for _, st := range states { + _, _ = ctrl.GetManualHold(st.ID, now) + } + return states + } + ctrl.SetFuseEVMax(func() (float64, bool) { + controlMu.Lock() + defer controlMu.Unlock() + _ = mgr.States() + return 11000, true + }) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + controlMu.Lock() + _, _ = svc.SlotDirectiveAt(now) + controlMu.Unlock() + } + }() + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + ctrl.Tick(context.Background(), now) + } + }() + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("goal snapshot inverted control/manager/hold locks") + } +} diff --git a/go/internal/mpc/loadpoint_directive.go b/go/internal/mpc/loadpoint_directive.go index 7c6162e4..12f87c43 100644 --- a/go/internal/mpc/loadpoint_directive.go +++ b/go/internal/mpc/loadpoint_directive.go @@ -15,6 +15,7 @@ func (d SlotDirective) LoadpointDirective() loadpoint.Directive { SlotEnd: d.SlotEnd, LoadpointEnergyWh: d.LoadpointEnergyWh, LoadpointMaxPowerW: d.LoadpointMaxPowerW, + LoadpointFallback: d.LoadpointFallback, } } diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index f8ddf377..05d3c05a 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/google/uuid" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -156,6 +157,9 @@ type Service struct { PVForecastSafetyK float64 Loadpoint LoadpointProbe // optional — when non-nil, the DP extends its state with EV dimensions Loadpoints LoadpointsProbe + // LoadpointStates supplies current saved goals without taking the control + // lock. It is independent of forecasts, prices and battery qualification. + LoadpointStates func() []loadpoint.State // SaveDiag is called synchronously after every successful replan that // remains the newest request, with the same Diagnostic the @@ -620,6 +624,8 @@ type SlotDirective struct { // remaining_s` formula it uses for the battery. LoadpointEnergyWh map[string]float64 LoadpointMaxPowerW map[string]float64 + LoadpointFallback map[string]bool + EVOnly bool // Current goal reserve; never an accepted battery plan. // LoadpointSoCTarget is the plan's EV SoC at SlotEnd per // loadpoint. Used by the per-loadpoint divergence check. @@ -644,10 +650,10 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { params := s.directiveParamsLocked(s.lastParams) s.mu.RUnlock() if p == nil || failedReplacement || !s.planExecutionAllowed(p, params.PVCurtailment, currentContract) { - return SlotDirective{}, false + return s.withDeadlineReserve(now, SlotDirective{}, false) } if time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { - return SlotDirective{}, false + return s.withDeadlineReserve(now, SlotDirective{}, false) } nowMs := now.UnixMilli() for _, a := range p.Actions { @@ -686,7 +692,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { d.LoadpointEnergyWh[id] = powerW * a.DurationHours() d.LoadpointSoCTarget[id] = a.LoadpointSoCByID[id] } - } else if a.LoadpointW > 0 && lpID != "" { + } else if lpID != "" { lpEnergyWh := a.LoadpointW * a.DurationHours() d.LoadpointEnergyWh = map[string]float64{ lpID: lpEnergyWh, @@ -695,9 +701,9 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { lpID: a.LoadpointSoC, } } - return d, true + return s.withDeadlineReserve(now, d, true) } - return SlotDirective{}, false + return s.withDeadlineReserve(now, SlotDirective{}, false) } // PlanWindow is one contiguous run of plan slots in which the active diff --git a/go/internal/telemetry/control_observations.go b/go/internal/telemetry/control_observations.go index 536406e3..50f97d35 100644 --- a/go/internal/telemetry/control_observations.go +++ b/go/internal/telemetry/control_observations.go @@ -30,7 +30,9 @@ type ControlBaseline struct { const ControlWindowDuration = 20 * time.Second -// Caller holds mu. At most one point per second and 64 points per DER. +// Caller holds mu. At most 64 points per DER. EVs retain every new source +// sample: dropping an observed stop could create false replacement credit. +// Other DERs retain at most one point per second for response checks. func (s *Store) recordControlObservation(driver string, kind DerType, power float64, data json.RawMessage, now time.Time) { if kind == DerVehicle { return @@ -50,7 +52,7 @@ func (s *Store) recordControlObservation(driver string, kind DerType, power floa if len(points) > 0 && !at.After(points[len(points)-1].At) { return } - if len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { + if kind != DerEV && len(points) > 0 && at.Unix() == points[len(points)-1].At.Unix() { points[len(points)-1] = p } else { points = append(points, p) diff --git a/go/internal/telemetry/control_observations_test.go b/go/internal/telemetry/control_observations_test.go index 43f66da2..f3e64050 100644 --- a/go/internal/telemetry/control_observations_test.go +++ b/go/internal/telemetry/control_observations_test.go @@ -31,6 +31,24 @@ func TestControlWindowsAreBoundedAndMissingDataBreaksProof(t *testing.T) { } } +func TestControlEVHistoryKeepsStopsWithinOneSecondAndStaysBounded(t *testing.T) { + s := NewStore() + at := time.Now().Truncate(time.Second) + for i, w := range []float64{11000, 0, 11000} { + s.recordControlObservation("car", DerEV, w, nil, at.Add(time.Duration(i)*100*time.Millisecond)) + } + points := s.controlObservations["car:ev"] + if len(points) != 3 || points[1].PowerW != 0 { + t.Fatalf("observed stop disappeared: %+v", points) + } + for i := 3; i < 1000; i++ { + s.recordControlObservation("car", DerEV, 11000, nil, at.Add(time.Duration(i)*100*time.Millisecond)) + } + if n := len(s.controlObservations["car:ev"]); n != 64 { + t.Fatalf("EV history unbounded: %v", n) + } +} + func TestProofDoesNotDependOnStatusPolling(t *testing.T) { s := NewStore() start := time.Now()