From e4b3f4754ceb629f7e187eccd892b70c4e4930ca Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 09:20:23 +0200 Subject: [PATCH 1/5] fix(loadpoint): a one-phase car on a three-phase charger is seen, planned and ordered as one A one-phase car draws a three-phase order's current per phase on one phase. On the field table the planner got [0, 4800], the car took 1,600 W of a 4,800 W order, and the feedback said not following. The loadpoint now tests each rise of its three-phase order on the site meter: one phase rising by about the order's rise, the other two still, for 30 s within the charger's response time, while the charger's own reading shows that draw. That marks the session's car one-phase (State.CarPhases, the field a car profile can set later); a rise on all three phases clears it, and unplugging resets it. A house load does not follow the orders, and a two-phase car moves two phases, so neither is marked. Marked, the planner gets the car's one-phase levels, 6 A up to the charger's current per phase. The charger stays on three phases and the order is the plan's power times three, so its current per phase is what the car should take. The command carries expected_w, a third of the order, and command evidence compares the car with that. The verdict is Limited, "Charging on one phase, at most 3.7 kW". The fuse limit is unchanged. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-one-phase-car.md | 5 + go/cmd/ftw/ev_planning_limits.go | 7 +- go/cmd/ftw/main.go | 5 + go/internal/api/api_control_feedback.go | 34 ++- go/internal/api/api_ev_one_phase_car_test.go | 89 ++++++++ go/internal/control/fuse_heat.go | 19 ++ go/internal/control/sitesim_test.go | 61 +++++- go/internal/loadpoint/controller.go | 21 ++ go/internal/loadpoint/loadpoint.go | 16 ++ go/internal/loadpoint/one_phase_car.go | 207 ++++++++++++++++++ go/internal/loadpoint/one_phase_car_test.go | 133 +++++++++++ go/internal/sitesim/sitesim.go | 7 +- go/internal/telemetry/command_evidence.go | 6 + .../telemetry/command_evidence_test.go | 16 ++ web/control-feedback.js | 2 + web/control-feedback.test.mjs | 6 + 16 files changed, 622 insertions(+), 12 deletions(-) create mode 100644 .changeset/ev-one-phase-car.md create mode 100644 go/internal/api/api_ev_one_phase_car_test.go create mode 100644 go/internal/loadpoint/one_phase_car.go create mode 100644 go/internal/loadpoint/one_phase_car_test.go diff --git a/.changeset/ev-one-phase-car.md b/.changeset/ev-one-phase-car.md new file mode 100644 index 000000000..eba44d36f --- /dev/null +++ b/.changeset/ev-one-phase-car.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A one-phase car, as many plug-in hybrids are, now gets what it can take from a three-phase charger. Such a car draws the order's current per phase on one phase only, so a 4.8 kW three-phase order gave it 1.6 kW while the plan counted 4.8 kW, and the charger panel said it was not following. FTW now sees it on the site meter: when its order rises, one phase rises by about the order's current and the other two stay put, while the charger's own reading shows the car drawing that much. For the rest of that charging session the plan counts the car's one-phase levels, up to the charger's current per phase (3.7 kW at 16 A), the charger stays on three phases, and the order names the current the car should take. The charger panel says "Charging on one phase, at most 3.7 kW" instead of "Not following". A house load, a three-phase car or a two-phase car is never taken for a one-phase car, and the next car starts unknown. The fuse limit is unchanged: it counts a three-phase order's current on every phase. diff --git a/go/cmd/ftw/ev_planning_limits.go b/go/cmd/ftw/ev_planning_limits.go index da187e397..570daee76 100644 --- a/go/cmd/ftw/ev_planning_limits.go +++ b/go/cmd/ftw/ev_planning_limits.go @@ -13,5 +13,10 @@ func planningStepsForLoadpoint(st loadpoint.State, cfg *config.Config) []float64 break } } - return loadpoint.PlanningSteps(lp, loadpoint.SiteFuse{MaxAmps: cfg.Fuse.MaxAmps, Voltage: cfg.Fuse.Voltage, PhaseCnt: cfg.Fuse.Phases}) + site := loadpoint.SiteFuse{MaxAmps: cfg.Fuse.MaxAmps, Voltage: cfg.Fuse.Voltage, PhaseCnt: cfg.Fuse.Phases} + if st.CarPhases == 1 { + // A one-phase car takes one phase of the charger's current. + return loadpoint.OnePhaseCarSteps(lp, site) + } + return loadpoint.PlanningSteps(lp, site) } diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 18cc4c43d..b79ff7e65 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2015,6 +2015,11 @@ func main() { defer ctrlMu.Unlock() return control.EVLimitReason(ctrl) }) + lpController.SetSitePhases(func() (loadpoint.SitePhases, bool) { + ctrlMu.Lock() + defer ctrlMu.Unlock() + return control.SitePhases(tel, ctrl) + }) // Every lease is preflighted and re-evaluated on each loadpoint tick. // This closure owns site/battery health because those concepts belong // to core, not to the protocol-neutral loadpoint package. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index f63a24a10..ebacb0e67 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "math" "sort" "time" @@ -56,9 +57,13 @@ type ControlFeedback struct { OfferedA *float64 `json:"offered_a"` DeviceLimitA *float64 `json:"device_limit_a"` DeviceReason string `json:"device_reason,omitempty"` - SinceMs int64 `json:"since_ms,omitempty"` - CommandAtMs int64 `json:"command_at_ms,omitempty"` - ObservedAtMs int64 `json:"observed_at_ms,omitempty"` + // ChargingPhase names the one phase (L1-L3) a one-phase car charges on + // under a three-phase order, and ChargingPhaseMaxW the most it takes. + ChargingPhase string `json:"charging_phase,omitempty"` + ChargingPhaseMaxW *float64 `json:"charging_phase_max_w,omitempty"` + SinceMs int64 `json:"since_ms,omitempty"` + CommandAtMs int64 `json:"command_at_ms,omitempty"` + ObservedAtMs int64 `json:"observed_at_ms,omitempty"` } type feedbackReading struct { @@ -103,8 +108,10 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { } observe, disabled := map[string]bool{}, map[string]bool{} configuredBattery := map[string]bool{} + var site loadpoint.SiteFuse if s.deps.Cfg != nil && s.deps.CfgMu != nil { s.deps.CfgMu.RLock() + site = loadpoint.SiteFuse{MaxAmps: s.deps.Cfg.Fuse.MaxAmps, Voltage: s.deps.Cfg.Fuse.Voltage, PhaseCnt: s.deps.Cfg.Fuse.Phases} observe = config.ObserveOnlyDriverSet(s.deps.Cfg) for _, d := range s.deps.Cfg.Drivers { disabled[d.Name] = d.Disabled @@ -205,6 +212,18 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { if f.RequestedW != nil && lp.Phases > 0 && lp.VoltageV > 0 { f.RequestedA = watts(*f.RequestedW / float64(lp.Phases) / lp.VoltageV) } + // A one-phase car takes a third of a three-phase order. + if lp.CarPhases == 1 && lp.CarPhase > 0 { + f.ChargingPhase = fmt.Sprintf("L%d", lp.CarPhase) + maxW := loadpoint.OnePhaseCarMaxW(loadpoint.Config{MaxChargeW: lp.MaxChargeW}, site) + if lp.CarMaxW > 0 { + maxW = math.Min(maxW, lp.CarMaxW) + } + f.ChargingPhaseMaxW = watts(maxW) + if f.RequestedW != nil && lp.Phases == 3 { + f.RequestedW = watts(*f.RequestedW / 3) + } + } if lp.PowerUnavailable { fresh = false f.ActualW = nil @@ -338,7 +357,7 @@ func setControlStatus(f *ControlFeedback) { "vehicle_complete", "vehicle_limit_completion", "vehicle_not_requesting": status = "following" case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", - "fuse_limit", "fuse_cooldown", "charger_limit": + "fuse_limit", "fuse_cooldown", "charger_limit", "one_phase": status = "limited" case "import_limit": status = "limited" @@ -441,6 +460,13 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("warning", "no_power_response", "warning") return } + // A one-phase car drawing a third of a three-phase order does what it + // can: the order is its current per phase, on its one phase. + if f.ChargingPhase != "" && commanded && cmd.Result == "accepted" && f.SentW != nil && *f.SentW > 100 && + f.ActualW != nil && math.Abs(*f.ActualW-*f.SentW) <= telemetry.ControlToleranceW(*f.SentW) { + set("limited", "one_phase", "info") + return + } if f.RequestedA != nil && f.DeviceLimitA != nil && *f.RequestedA > *f.DeviceLimitA+0.5 { set("limited", "device_limit", "warning") return diff --git a/go/internal/api/api_ev_one_phase_car_test.go b/go/internal/api/api_ev_one_phase_car_test.go new file mode 100644 index 000000000..856c059b5 --- /dev/null +++ b/go/internal/api/api_ev_one_phase_car_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "math" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A one-phase car (a plug-in hybrid) on a three-phase charger, the field +// site's table and a plan for 4.8 kW, through a night. It takes the order's +// current per phase on L1. Once the meter shows that, the verdict says so +// and the car is never "not following" for doing what it can. +func TestOnePhaseCarThroughANight(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("easee") + mgr := loadpoint.NewManager() + steps := []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, + 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: steps}}) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: .8}) + var orderW float64 + var orderAt time.Time + drawW := func() float64 { + if orderW <= 0 || time.Since(orderAt) < 40*time.Second { + return 0 + } + return math.Min(math.Floor(orderW/690+.5), 16) * 230 + } + plan := func(now time.Time) (loadpoint.Directive, bool) { + slot := now.Truncate(15 * time.Minute) + return loadpoint.Directive{SlotStart: slot, SlotEnd: slot.Add(15 * time.Minute), LoadpointEnergyWh: map[string]float64{"garage": 4800.0 / 4}}, true + } + sample := func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: drawW(), PowerAt: time.Now()}, true + } + send := func(_ context.Context, driver string, payload []byte) error { + c := tel.BeginCommand(driver, payload, time.Now()) + tel.CompleteCommand(c, "accepted") + var cmd struct { + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &cmd); err != nil { + return err + } + if cmd.PowerW != orderW { + orderW, orderAt = cmd.PowerW, time.Now() + } + return nil + } + ctrl := loadpoint.NewController(mgr, plan, sample, send) + ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3}) + ctrl.SetSitePhases(func() (loadpoint.SitePhases, bool) { + return loadpoint.SitePhases{A: [3]float64{1.6 + drawW()/230, 1.7, 2.3}, At: time.Now()}, true + }) + srv := New(&Deps{Tel: tel, Loadpoints: mgr, Cfg: &config.Config{Fuse: config.Fuse{MaxAmps: 20, Voltage: 230, Phases: 3}}, CfgMu: &sync.RWMutex{}}) + changedAt := time.Now() + last := -1.0 + var f ControlFeedback + for range 8 * 3600 / 5 { + ctrl.Tick(context.Background(), time.Now()) + if w := drawW(); w != last { + last, changedAt = w, time.Now() + } + tel.Update("easee", telemetry.DerEV, drawW(), nil, []byte(fmt.Sprintf( + `{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180}`, + changedAt.Format(time.RFC3339Nano)))) + time.Sleep(5 * time.Second) + f = srv.controlFeedback(time.Now())[0] + if f.Status == "not_following" { + t.Fatalf("at %v a one-phase car drawing %.0f W of a %.0f W order reads %s/%s", time.Since(changedAt), drawW(), orderW, f.Status, f.Reason) + } + } + st, _ := mgr.State("garage") + if st.CarPhases != 1 || f.Reason != "one_phase" || f.Severity != "info" || f.ChargingPhase != "L1" || + f.ChargingPhaseMaxW == nil || *f.ChargingPhaseMaxW != 3680 || drawW() != 3680 { + t.Fatalf("after a night: car phases %d, draw %.0f W, verdict %s/%s/%s on %q", st.CarPhases, drawW(), f.Status, f.Reason, f.Severity, f.ChargingPhase) + } + }) +} diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 305d9f5ab..e80c5e70e 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -5,6 +5,7 @@ import ( "math" "time" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -122,3 +123,21 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, } return amps, true, true } + +// SitePhases is the site meter's last reading of its phase currents, for +// the loadpoints' one-phase car check. false when the meter reports no +// usable phase currents or the site has fewer than three phases. +func SitePhases(store *telemetry.Store, state *State) (loadpoint.SitePhases, bool) { + if store == nil || state == nil || state.SiteFusePhases != 3 { + return loadpoint.SitePhases{}, false + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if meter == nil { + return loadpoint.SitePhases{}, false + } + amps, _, valid := phaseCurrentsA(meter, state) + if !valid || len(amps) != 3 { + return loadpoint.SitePhases{}, false + } + return loadpoint.SitePhases{A: [3]float64(amps), At: meter.UpdatedAt}, true +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 21b9d5572..61e0fba67 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -106,6 +106,7 @@ func (s *simSite) boot() { }) s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) s.ctrl.SetFuseLimitReason(func() string { return EVLimitReason(s.state) }) + s.ctrl.SetSitePhases(func() (loadpoint.SitePhases, bool) { return SitePhases(s.store, s.state) }) s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { if !s.meterFresh() { return 0, false @@ -468,6 +469,22 @@ func simLoadpoint() loadpoint.Config { func houseA(l1, l2, l3 float64) [3]float64 { return [3]float64{l1 * 230, l2 * 230, l3 * 230} } +// fieldNightLoadpoint is the 22 Oct 2026 field site's Easee: auto, with +// one-phase levels up to 3.8 kW and three-phase levels from 4.8 kW. +func fieldNightLoadpoint(c *loadpoint.Config) { + c.MinChargeW, c.MaxChargeW, c.PhaseMode = 1600, 11100, "auto" + c.AllowedStepsW = []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, + 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} +} + +// carPhasesUnknown checks that the session's car was never marked one-phase. +func carPhasesUnknown(s *simSite) error { + if st, _ := s.mgr.State(s.lp.ID); st.CarPhases != 0 { + return fmt.Errorf("car marked one-phase on L%d; car draws %.0f W on %d phases", st.CarPhase, s.p.EVSE.DrawW, s.p.EVSE.DrawPhases) + } + return nil +} + // planEV is a saved goal and a plan that gives the car w in every quarter // hour: the path Core takes overnight. func planEV(w float64) loadpoint.PlanFunc { @@ -628,11 +645,7 @@ func simScenarios() []simScenario { plant: func(p *sitesim.Plant) { p.Fuse.RatingA, p.HouseW, p.EVSE.Delay = 20, houseA(1.6, 1.7, 2.3), 40*time.Second }, - lp: func(c *loadpoint.Config) { - c.MinChargeW, c.MaxChargeW, c.PhaseMode = 1600, 11100, "auto" - c.AllowedStepsW = []float64{0, 1600, 1900, 2100, 2400, 2600, 2800, 3100, 3300, 3500, 3800, - 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} - }, + lp: fieldNightLoadpoint, plan: planEV(11100), expect: []simExpect{ {2 * time.Minute, func(s *simSite) error { @@ -648,6 +661,44 @@ func simScenarios() []simScenario { return nil }}, }}, + // A one-phase car (a plug-in hybrid) on the field night's table: the + // plan's 4.8 kW three-phase order is 7 A a phase, which the car takes + // on L1 alone, 1.6 kW. Once the meter shows it, the order is the + // current per phase the car should take, and it gets 3.7 kW as fast + // as the fuse limit lets the order rise. + {name: "one-phase car, field table", start: time.Date(2026, 10, 22, 3, 0, 0, 0, time.Local), run: 15 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW, p.EVSE.Delay = 20, houseA(1.6, 1.7, 2.3), 40*time.Second + p.Car.Phases, p.Car.MaxW = 1, 3700 + }, + lp: fieldNightLoadpoint, + plan: planEV(4800), + expect: []simExpect{{8 * time.Minute, func(s *simSite) error { + st, _ := s.mgr.State(s.lp.ID) + if st.CarPhases != 1 || st.CarPhase != 1 || s.p.EVSE.DrawW < 3600 { + return fmt.Errorf("car phases %d (L%d), car draws %.0f W", st.CarPhases, st.CarPhase, s.p.EVSE.DrawW) + } + return nil + }}}}, + // A house load on one phase as a three-phase car starts, and a + // two-phase car: neither is one-phase. + {name: "three-phase car, kettle on L1 at the start", start: time.Date(2026, 10, 22, 3, 0, 0, 0, time.Local), run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW, p.EVSE.Delay, p.Car.MaxW = 25, houseA(1, 1, 1), 40*time.Second, 7360 + p.Events = []sitesim.Event{ + {At: 5 * time.Second, Do: func(p *sitesim.Plant) { p.HouseW = houseA(10, 1, 1) }}, + {At: 4 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(1, 1, 1) }}, + } + }, + lp: fieldNightLoadpoint, plan: planEV(11100), + expect: []simExpect{{10*time.Minute - time.Second, carPhasesUnknown}}}, + {name: "two-phase car", start: time.Date(2026, 10, 22, 3, 0, 0, 0, time.Local), run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW, p.EVSE.Delay = 25, houseA(1, 1, 1), 40*time.Second + p.Car.Phases, p.Car.MaxW = 2, 7360 + }, + lp: fieldNightLoadpoint, plan: planEV(11100), + expect: []simExpect{{10*time.Minute - time.Second, carPhasesUnknown}}}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index bd61fe67e..89bdca6be 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -51,6 +51,8 @@ type Controller struct { fusePhaseRoomA func() (float64, bool) // fuseLimitReason names the site limit behind the cap. fuseLimitReason func() string + // sitePhases reads the site meter's phases for the one-phase car check. + sitePhases func() (SitePhases, bool) // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 @@ -1325,6 +1327,7 @@ func (c *Controller) observeTick(now time.Time, cfg Config, sample EVSample, dis wasSurplus := cfg.SurplusOnly || (!sched.HasTarget() && armed) c.manager.SetSurplusWithheld(cfg.ID, wasSurplus && enteringSurplusPaused) c.manager.ObserveSample(cfg.ID, sample) + c.observePhases(cfg, sample, now) c.restoreManualHoldForSession(cfg.ID) c.evaluateBatteryBoost(cfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) } @@ -1645,6 +1648,13 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s c.manager.setCommandedPhases(lpCfg.ID, c.commandPhases(mode, w, split)) c.saveOffer(now, lpCfg, sample, w, fusePhase(mode), cmdReason) } + // A one-phase car takes a third of a three-phase order: command evidence + // compares its draw with that. + if st, known := c.manager.State(lpCfg.ID); known && haveOffer && st.CarPhases == 1 && c.siteFuse().Phases() == 3 { + if mode, _ := cmd["phase_mode"].(string); fusePhase(mode) == "3p" { + cmd["expected_w"] = offerW / 3 + } + } payload, err := json.Marshal(cmd) if err != nil { return @@ -2483,8 +2493,19 @@ func (c *Controller) computeCommandForBudget(now time.Time, lpCfg Config, curren } remainingWh := budgetWh - alreadyWh wantW := EnergyBudgetToPowerW(remainingWh, remainingS) + // A one-phase car takes a three-phase order's current per phase on its + // one phase. The plan counts what it takes; the order names that current + // on all three, within the charger's maximum. + onePhaseCar := false + if st, known := c.manager.State(lpCfg.ID); known && st.CarPhases == 1 && c.siteFuse().Phases() == 3 && + fusePhase(c.tickChoices[lpCfg.ID].phaseMode) == "3p" { + onePhaseCar, wantW = true, wantW*3 + } maxW := lpCfg.MaxChargeW if ceiling, ok := d.LoadpointMaxPowerW[lpCfg.ID]; ok { + if onePhaseCar { + ceiling *= 3 + } maxW = min(maxW, ceiling) if maxW <= 0 || remainingWh <= 1e-6 || remainingS <= 0 { return 0, true, false diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 0c84bb128..7bcb105fa 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -203,6 +203,13 @@ type State struct { // PlanningTarget). CarMaxW float64 `json:"-"` CarChargeLimit float64 `json:"-"` + // CarPhases is 1 when this session's car draws on one phase of a + // three-phase order, seen on the site meter, and 0 when unknown; a car + // profile's phases may set it later. CarPhase is that phase, 1-3. The + // plan counts the car's one-phase levels (OnePhaseCarSteps) and the + // order names the current per phase it should take. + CarPhases int `json:"-"` + CarPhase int `json:"-"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the // UI has everything for rendering in one fetch. @@ -408,6 +415,9 @@ type loadpointRuntime struct { goalRiskNotified bool vehicleAnchored bool chargedSinceStart bool + // carPhases is this session's evidence of a one-phase car + // (one_phase_car.go). + carPhases carPhases Config pluggedIn bool @@ -847,11 +857,13 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false + lp.carPhases = carPhases{} } if !pluggedIn { // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} lp.chargingDeclined = false + lp.carPhases = carPhases{} if lp.capacityFromCar { // The car that measured its own capacity left with its // session — the next one may be different. @@ -1368,6 +1380,10 @@ func (lp *loadpointRuntime) snapshot() State { CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, CommandedPhases: lp.commandedPhases, + CarPhase: lp.carPhases.phase, + } + if lp.carPhases.phase > 0 { + st.CarPhases = 1 } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/loadpoint/one_phase_car.go b/go/internal/loadpoint/one_phase_car.go new file mode 100644 index 000000000..fb3118e77 --- /dev/null +++ b/go/internal/loadpoint/one_phase_car.go @@ -0,0 +1,207 @@ +package loadpoint + +import ( + "math" + "time" +) + +// A one-phase car, as many plug-in hybrids are, draws on one phase of a +// three-phase charger: the order's current per phase, on that phase alone. +// The charger stays on three phases. The fuse already counts a three-phase +// order's current on every phase, which covers whichever phase the car +// uses. What changes is what the car gets: the plan counts one phase, and +// the order names the current per phase the car should take. +// +// The site meter shows it. When FTW's three-phase order rises by at least +// onePhaseStepA a phase, the meter's change from just before is watched over +// the charger's response time. One phase rising by about the order's change +// while the other two stay put is a one-phase car; all three rising is not. +// A house load does not follow FTW's orders, and the charger's own reading +// must show the car drawing what that phase shows, so a kettle cannot mark +// a car. A two-phase car moves two phases and is never marked. + +const ( + onePhaseStepA = 2.0 // the smallest order rise that is a test + onePhaseHold = 30 * time.Second // a verdict holds this long on the meter + // onePhaseSettle is the least response time a test waits. + onePhaseSettle = time.Minute +) + +// SitePhases is the site meter's current on each phase and when it read +// them. +type SitePhases struct { + A [3]float64 + At time.Time +} + +// phaseTest is one order rise being watched on the meter. +type phaseTest struct { + at, until time.Time + baseline [3]float64 // the meter just before the order took effect + fromA float64 // the order per phase before the rise + verdict int // 1-3: one phase, that one; -1: three phases; 0: none + since time.Time // when the meter began to show the verdict +} + +// carPhases is one session's evidence. phase is 1-3 once the car is seen on +// that phase alone. +type carPhases struct { + orderA float64 // per phase, the order the next rise is measured from + test *phaseTest + phase int +} + +// phaseVerdict reads one meter reading against the baseline of a rise of +// riseA a phase. drawA is the charger's own power over one phase's voltage. +func phaseVerdict(amps, baseline [3]float64, riseA, drawA float64) int { + var moved [3]float64 + k, big := 0, math.Inf(-1) + for i := range amps { + moved[i] = amps[i] - baseline[i] + if moved[i] > big { + k, big = i, moved[i] + } + } + // The car's phase rises by the order's rise at most, and by at least + // half of it once the charger has applied it. + if !finite(big) || big < math.Max(onePhaseStepA, riseA/2) || big > riseA+onePhaseStepA { + return 0 + } + quiet, rose := 0, 0 + for i, m := range moved { + if i == k { + continue + } + if math.Abs(m) <= math.Max(1, big/4) { + quiet++ + } + if m >= big/2 { + rose++ + } + } + switch { + case rose == 2: + return -1 + case quiet == 2 && finite(drawA) && drawA >= 0.6*big && drawA <= 1.5*big: + return k + 1 + } + return 0 +} + +// SetSitePhases wires the site meter's phase currents for the one-phase car +// check. nil turns the check off. +func (c *Controller) SetSitePhases(f func() (SitePhases, bool)) { + if c != nil { + c.sitePhases = f + } +} + +// observePhases runs this tick's one-phase car check on a fresh meter +// reading. The response time is the charger's declared power age, at least +// onePhaseSettle. +func (c *Controller) observePhases(cfg Config, sample EVSample, now time.Time) { + site := c.siteFuse() + if c.sitePhases == nil || !sample.Connected || site.Phases() != 3 { + return + } + meter, ok := c.sitePhases() + if !ok || meter.At.After(now.Add(time.Second)) || now.Sub(meter.At) > 10*time.Second { + return + } + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + drawA := math.NaN() + if !sample.PowerUnavailable && finite(sample.PowerW) { + drawA = sample.PowerW / voltage + } + c.manager.observePhases(cfg.ID, meter, drawA, voltage, max(onePhaseSettle, sample.PowerWindow()), now) +} + +// observePhases starts a test when the session's three-phase order rises +// by onePhaseStepA a phase, and judges it on the meter until the charger's +// response time and onePhaseHold have passed. The order may rise further +// meanwhile, as a plan catching up does; the test then expects that rise. +// A test whose order falls back is dropped. A one-phase verdict marks the +// session's car; a three-phase one clears the mark. +func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage float64, settle time.Duration, now time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn { + return + } + s := &lp.carPhases + if !lp.commandedKnown || lp.commandedPhases != 3 || !finite(lp.commandedW) { + s.test = nil + return + } + orderA := math.Max(0, lp.commandedW) / (3 * voltage) + if t := s.test; t != nil && (orderA-t.fromA < onePhaseStepA || now.After(t.until)) { + s.test = nil + } + if s.test == nil { + switch { + case orderA-s.orderA >= onePhaseStepA: + s.test = &phaseTest{at: now, until: now.Add(settle + onePhaseHold), baseline: meter.A, fromA: s.orderA} + case orderA < s.orderA: + s.orderA = orderA // A cut is the base of the next rise. + } + if s.test == nil { + return + } + s.orderA = orderA + } + t := s.test + s.orderA = max(s.orderA, orderA) + if meter.At.Before(t.at) { + return + } + if v := phaseVerdict(meter.A, t.baseline, orderA-t.fromA, drawA); v != t.verdict { + t.verdict, t.since = v, now + } + if t.verdict == 0 || now.Sub(t.since) < onePhaseHold { + return + } + s.phase = max(t.verdict, 0) + s.test = nil +} + +// chargerPhaseMaxA is the most current a charger offers on each phase of a +// three-phase order: its maximum over three phases, within the site fuse. +func chargerPhaseMaxA(cfg Config, site SiteFuse) float64 { + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + maxA := 32.0 + if finite(cfg.MaxChargeW) && cfg.MaxChargeW > 0 { + maxA = math.Floor(cfg.MaxChargeW/(3*voltage) + 1e-9) + } + if finite(site.MaxAmps) && site.MaxAmps > 0 { + maxA = math.Min(maxA, math.Floor(site.MaxAmps)) + } + return maxA +} + +// OnePhaseCarSteps are the levels a one-phase car takes from a three-phase +// charger: 6 A up to the charger's current per phase, on one phase. +func OnePhaseCarSteps(cfg Config, site SiteFuse) []float64 { + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + steps := []float64{0} + for a := 6.0; a <= chargerPhaseMaxA(cfg, site); a++ { + steps = append(steps, a*voltage) + } + return steps +} + +// OnePhaseCarMaxW is the most a one-phase car takes from the charger: its +// current per phase on one phase. +func OnePhaseCarMaxW(cfg Config, site SiteFuse) float64 { + steps := OnePhaseCarSteps(cfg, site) + return steps[len(steps)-1] +} diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go new file mode 100644 index 000000000..2b958cd55 --- /dev/null +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -0,0 +1,133 @@ +package loadpoint + +import ( + "context" + "encoding/json" + "math" + "slices" + "testing" + "time" +) + +func TestPhaseVerdict(t *testing.T) { + base := [3]float64{1.6, 1.7, 2.3} + plus := func(a, b, c float64) [3]float64 { return [3]float64{base[0] + a, base[1] + b, base[2] + c} } + for _, c := range []struct { + name string + amps [3]float64 + drawA float64 + want int + }{ + {"one-phase car on L1", plus(7, 0, 0), 7, 1}, + {"one-phase car on L3", plus(0, 0.3, 7), 7, 3}, + {"car limited below the order", plus(5, 0, 0), 5, 1}, + {"three-phase car", plus(7, 7, 7), 21, -1}, + {"two-phase car", plus(7, 7, 0), 14, 0}, + {"kettle while the car draws nothing", plus(9, 0, 0), 0, 0}, + {"car and kettle on one phase", plus(16, 0, 0), 7, 0}, + {"not applied yet", plus(0, 0, 0), 0, 0}, + } { + if got := phaseVerdict(c.amps, base, 7, c.drawA); got != c.want { + t.Errorf("%s: verdict %d, want %d", c.name, got, c.want) + } + } +} + +// The meter marks the session's car: a three-phase order rises by 7 A a +// phase, and 40 s later only L1 rises by 7 A while the charger reads 1.6 kW. +// A three-phase verdict clears it, and the next car starts unknown. +func TestOnePhaseCarMarkedFromTheMeter(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee", MinChargeW: 1600, MaxChargeW: 11100}}) + m.Observe("garage", true, 0, 0, true) + start := time.Date(2026, 10, 22, 3, 0, 0, 0, time.UTC) + base := [3]float64{1.6, 1.7, 2.3} + order := func(w float64) { m.SetCommanded("garage", w, "plan"); m.setCommandedPhases("garage", 3) } + run := func(from, to time.Duration, amps func(time.Duration) [3]float64, drawW float64) { + for d := from; d <= to; d += 5 * time.Second { + at := start.Add(d) + m.observePhases("garage", SitePhases{A: amps(d), At: at}, drawW/230, 230, time.Minute, at) + } + } + state := func() State { st, _ := m.State("garage"); return st } + still := func(time.Duration) [3]float64 { return base } + + order(0) + run(0, 10*time.Second, still, 0) + order(4830) + onL1 := func(d time.Duration) [3]float64 { + if d < 55*time.Second { + return base + } + return [3]float64{base[0] + 7, base[1], base[2]} + } + run(15*time.Second, 80*time.Second, onL1, 1610) + if st := state(); st.CarPhases != 0 { + t.Fatal("marked before the meter showed the rise for 30 s") + } + run(85*time.Second, 90*time.Second, onL1, 1610) + if st := state(); st.CarPhases != 1 || st.CarPhase != 1 { + t.Fatalf("after 35 s on L1 alone: car phases %d on L%d", st.CarPhases, st.CarPhase) + } + // A later rise that shows on all three phases is a three-phase car. + order(11040) + allThree := func(d time.Duration) [3]float64 { + if d < 140*time.Second { + return [3]float64{base[0] + 7, base[1], base[2]} + } + return [3]float64{base[0] + 16, base[1] + 9, base[2] + 9} + } + run(95*time.Second, 200*time.Second, allThree, 11040) + if st := state(); st.CarPhases != 0 { + t.Fatalf("a rise on all three phases left the mark on L%d", st.CarPhase) + } + order(4830) + run(205*time.Second, 210*time.Second, still, 0) + order(11040) + run(215*time.Second, 300*time.Second, onL1, 1610) + m.Observe("garage", false, 0, 0, true) + m.Observe("garage", true, 0, 0, true) + if st := state(); st.CarPhases != 0 { + t.Fatal("the next car would inherit the mark") + } +} + +// Reviewer's P4: a one-phase car on the field table. Without the mark the +// planner got [0, 4800] and the car drew 1.6 kW of a 4.8 kW order. Marked, +// the planner gets the car's one-phase levels up to 3.7 kW. +func TestOnePhaseCarPlansItsOwnLevels(t *testing.T) { + site := SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3} + cfg := Config{MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: easeeFieldSteps} + st := State{MinChargeW: 1600, MaxChargeW: 11100, CarMaxW: 3700} + _, maxW, steps := LimitPower(st, OnePhaseCarSteps(cfg, site)) + if maxW < 3680 || maxW > 3700 || steps[len(steps)-1] != 3680 || !slices.Contains(steps, 1380) { + t.Fatalf("one-phase car plan: max %.0f W, steps %v", maxW, steps) + } + if got := OnePhaseCarMaxW(Config{MaxChargeW: 11100}, SiteFuse{MaxAmps: 10, Voltage: 230, PhaseCnt: 3}); got != 2300 { + t.Fatalf("a 10 A fuse holds a one-phase car to %.0f W, want 2300 W", got) + } +} + +// Marked, a plan slot at the car's 3.7 kW goes out as a three-phase order +// of 16 A a phase, and the command says the car should draw a third of it. +func TestOnePhaseCarOrderNamesItsCurrent(t *testing.T) { + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 1600, MaxChargeW: 11100, PhaseMode: "auto", AllowedStepsW: easeeFieldSteps} + slot := time.Now().Add(-time.Second) + dir := &Directive{SlotStart: slot, SlotEnd: slot.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 3680}} + var payload map[string]any + send := func(_ context.Context, _ string, p []byte) error { return json.Unmarshal(p, &payload) } + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}} + c := NewController(NewManager(), PlanFunc(func(time.Time) (Directive, bool) { return *dir, true }), + TelemetryFunc(func(d string) (EVSample, bool) { s, ok := samples[d]; return s, ok }), send) + c.manager.Load([]Config{cfg}) + c.SetSiteFuse(SiteFuse{MaxAmps: 20, Voltage: 230, PhaseCnt: 3}) + c.manager.Observe(cfg.ID, true, 0, 0, true) + c.manager.SetSchedule(cfg.ID, Schedule{SoC: .8}) + c.manager.byID[cfg.ID].carPhases.phase = 1 + c.Tick(context.Background(), time.Now()) + power, _ := payload["power_w"].(float64) + expected, _ := payload["expected_w"].(float64) + if payload["phase_mode"] != "3p" || power != 11100 || math.Abs(expected-3700) > 1 { + t.Fatalf("sent %v", payload) + } +} diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 632d0503c..0ed284435 100644 --- a/go/internal/sitesim/sitesim.go +++ b/go/internal/sitesim/sitesim.go @@ -139,10 +139,13 @@ func (p *Plant) Step() Tick { return tick } -// spread puts w on L1 alone or evenly on three phases. +// spread puts w on L1 alone, evenly on L1 and L2, or evenly on three phases. func spread(w float64, phases int) [3]float64 { - if phases == 1 { + switch phases { + case 1: return [3]float64{w, 0, 0} + case 2: + return [3]float64{w / 2, w / 2, 0} } return [3]float64{w / 3, w / 3, w / 3} } diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go index 528205359..5761d98a8 100644 --- a/go/internal/telemetry/command_evidence.go +++ b/go/internal/telemetry/command_evidence.go @@ -104,10 +104,16 @@ func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) Comma var request struct { Action string `json:"action"` PowerW *float64 `json:"power_w"` + // ExpectedW is what the device should draw when it differs from + // the order: a one-phase car takes a third of a three-phase one. + ExpectedW *float64 `json:"expected_w"` } if json.Unmarshal(payload, &request) != nil { return CommandEvidence{} } + if request.ExpectedW != nil && finite(*request.ExpectedW) && *request.ExpectedW >= 0 { + request.PowerW = request.ExpectedW + } // Resume changes permission to draw, not the current limit. Keep the // accepted limit and its measured response while this optional call runs. if request.Action == "ev_resume" && request.PowerW == nil { diff --git a/go/internal/telemetry/command_evidence_test.go b/go/internal/telemetry/command_evidence_test.go index d45adce7e..84125808b 100644 --- a/go/internal/telemetry/command_evidence_test.go +++ b/go/internal/telemetry/command_evidence_test.go @@ -202,3 +202,19 @@ func TestRepeatedOrderKeepsEarlierOrderInForce(t *testing.T) { t.Fatalf("1 min after a cut the earlier 7590 W order was out of force: gap %v, mismatch since %v", got.LastGapW, got.PowerMismatchSince) } } + +// A one-phase car takes a third of a three-phase order. The command says +// what it should draw, and the evidence compares the car with that. +func TestExpectedDrawReplacesTheOrder(t *testing.T) { + s := NewStore() + start := time.Now() + c := s.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":11100,"expected_w":3700}`), start) + s.CompleteCommand(c, "accepted") + s.mu.Lock() + s.observeCommand("easee", DerEV, 3680, json.RawMessage(`{}`), start.Add(3*time.Minute)) + s.mu.Unlock() + got, _ := s.CommandEvidence("easee", "ev") + if got.PowerW == nil || *got.PowerW != 3700 || !got.PowerMismatchSince.IsZero() { + t.Fatalf("evidence %+v", got) + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index 69c97b1ea..e81644039 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -66,6 +66,7 @@ const REASON_TITLES = { core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a site power limit', import_limit: 'Limited by the hourly import limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', + one_phase: 'Charging on one phase', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', response_unknown: 'No power reading', observe_only: 'Monitoring only', disabled: 'Disabled', device_control: 'Own control', @@ -99,6 +100,7 @@ function sentence(row) { case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; + case 'one_phase': return `The car charges on ${row.charging_phase || 'one phase'} only${num(row.charging_phase_max_w) ? `, at most ${controlPower(row.charging_phase_max_w)}` : ''}. FTW plans with what it takes.`; case 'power_below_target': case 'power_above_target': return `Asked for ${target(row)}, delivering ${controlPower(actual)}.`; case 'power_wrong_direction': return `Asked for ${target(row)}, but it is ${doing(row, actual)} ${controlPower(actual)}.`; case 'no_power_response': return `Asked for ${target(row)}, but it delivers no power.`; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index b504dd07c..9250313ca 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -59,6 +59,12 @@ test('expected limits stay calm and explain themselves', () => { assert.equal(car.title, 'Car is full'); const limit = controlStatus(row({kind: 'ev', status: 'limited', reason: 'device_limit', severity: 'warning', device_limit_a: 8, requested_a: 16})); assert.equal(limit.text, 'The charger’s own limit is 8 A, below the 16 A FTW asked for.'); + const onePhase = controlStatus(row({kind: 'ev', mode: 'plan', status: 'limited', reason: 'one_phase', severity: 'info', + sent_w: 3680, actual_w: 3650, charging_phase: 'L1', charging_phase_max_w: 3680})); + assert.equal(onePhase.title, 'Charging on one phase'); + assert.equal(onePhase.text, 'The car charges on L1 only, at most 3.7 kW. FTW plans with what it takes.'); + assert.equal(onePhase.next, ''); + assert.equal(onePhase.tone, 'neutral'); }); test('the receipt walks from sent to confirmed without inventing proof', () => { From f29ec9a3816391a3e5192dc5c0f27e9cfd2fca0d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 10:09:32 +0200 Subject: [PATCH 2/5] fix(loadpoint): one-phase car tests on fast and undeclared chargers; field rows The field matrix showed two gaps. A charger that applies in 2 s has already taken a raise by the next reading, and a fuse ramp climbs 1 A at a time, so a test's baseline must be the meter read before the first raise since the order it measures from. A charger that declares no response time is now given 3 minutes, as the fuse limit counts it, not one. Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn green. Every one-phase-car row now meets its energy, verdict and one-phase invariants. 25 of them still cut twice when a house step on the car's phase meets the second cut of the netting class; they are labelled netting. Co-Authored-By: Claude Opus 5.5 --- go/internal/loadpoint/one_phase_car.go | 39 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/go/internal/loadpoint/one_phase_car.go b/go/internal/loadpoint/one_phase_car.go index fb3118e77..b7ab941a0 100644 --- a/go/internal/loadpoint/one_phase_car.go +++ b/go/internal/loadpoint/one_phase_car.go @@ -23,8 +23,11 @@ import ( const ( onePhaseStepA = 2.0 // the smallest order rise that is a test onePhaseHold = 30 * time.Second // a verdict holds this long on the meter - // onePhaseSettle is the least response time a test waits. - onePhaseSettle = time.Minute + // onePhaseSettle is the least response time a test waits, and + // onePhaseUnknownSettle the response time of a charger that declares + // none, as the fuse limit counts it. + onePhaseSettle = time.Minute + onePhaseUnknownSettle = 3 * time.Minute ) // SitePhases is the site meter's current on each phase and when it read @@ -47,6 +50,12 @@ type phaseTest struct { // that phase alone. type carPhases struct { orderA float64 // per phase, the order the next rise is measured from + // rising is set once the order has gone above orderA, and before is the + // meter read before that first raise went out: a fast charger has + // applied it by the next reading, and a fuse ramp climbs 1 A at a time. + rising bool + before [3]float64 + last [3]float64 // the meter read before this tick's command test *phaseTest phase int } @@ -98,7 +107,7 @@ func (c *Controller) SetSitePhases(f func() (SitePhases, bool)) { // observePhases runs this tick's one-phase car check on a fresh meter // reading. The response time is the charger's declared power age, at least -// onePhaseSettle. +// onePhaseSettle, or onePhaseUnknownSettle when it declares none. func (c *Controller) observePhases(cfg Config, sample EVSample, now time.Time) { site := c.siteFuse() if c.sitePhases == nil || !sample.Connected || site.Phases() != 3 { @@ -116,7 +125,11 @@ func (c *Controller) observePhases(cfg Config, sample EVSample, now time.Time) { if !sample.PowerUnavailable && finite(sample.PowerW) { drawA = sample.PowerW / voltage } - c.manager.observePhases(cfg.ID, meter, drawA, voltage, max(onePhaseSettle, sample.PowerWindow()), now) + settle := onePhaseUnknownSettle + if sample.PowerMaxAge > 0 { + settle = max(onePhaseSettle, sample.PowerWindow()) + } + c.manager.observePhases(cfg.ID, meter, drawA, voltage, settle, now) } // observePhases starts a test when the session's three-phase order rises @@ -133,8 +146,10 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa return } s := &lp.carPhases + previous := s.last + s.last = meter.A if !lp.commandedKnown || lp.commandedPhases != 3 || !finite(lp.commandedW) { - s.test = nil + s.test, s.rising = nil, false return } orderA := math.Max(0, lp.commandedW) / (3 * voltage) @@ -143,15 +158,17 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa } if s.test == nil { switch { - case orderA-s.orderA >= onePhaseStepA: - s.test = &phaseTest{at: now, until: now.Add(settle + onePhaseHold), baseline: meter.A, fromA: s.orderA} - case orderA < s.orderA: - s.orderA = orderA // A cut is the base of the next rise. + case orderA <= s.orderA: + s.orderA, s.rising = orderA, false // A cut is the base of the next rise. + return + case !s.rising: + s.rising, s.before = true, previous } - if s.test == nil { + if orderA-s.orderA < onePhaseStepA { return } - s.orderA = orderA + s.test = &phaseTest{at: now, until: now.Add(settle + onePhaseHold), baseline: s.before, fromA: s.orderA} + s.orderA, s.rising = orderA, false } t := s.test s.orderA = max(s.orderA, orderA) From 45ff74daa5f2b0c4c414bba27d0780a5939201f9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:36:40 +0200 Subject: [PATCH 3/5] fix(loadpoint): one-phase car check compares changes, guards a clear, and survives a restart Review findings on #1801: M1: the check compared the charger's whole draw with the meter's rise, so only a rise from about 0 A could mark a car. It now compares the change in the charger's draw since the baseline. M2: a three-phase verdict had no draw check, so a heat pump starting at 2 A a phase during a 2 A rise cleared a correct mark. It now needs the charger's draw to have risen by about the three phases' sum. M3: the first reading of a session had a zero baseline, and the mark lives in memory, so after a Core restart with the car charging it was never marked again. A reading with no baseline is no longer a test, and an unmarked car whose charger's whole draw shows on one phase while the other two carry less than a third of it, for 30 s, is marked. No three- or two-phase car can show that. Each review probe is a test, and each fails on the previous head. Co-Authored-By: Claude Opus 5.5 --- go/internal/loadpoint/one_phase_car.go | 93 ++++++++++++--- go/internal/loadpoint/one_phase_car_test.go | 121 +++++++++++++++++++- 2 files changed, 190 insertions(+), 24 deletions(-) diff --git a/go/internal/loadpoint/one_phase_car.go b/go/internal/loadpoint/one_phase_car.go index b7ab941a0..ca2c1d816 100644 --- a/go/internal/loadpoint/one_phase_car.go +++ b/go/internal/loadpoint/one_phase_car.go @@ -17,8 +17,12 @@ import ( // the charger's response time. One phase rising by about the order's change // while the other two stay put is a one-phase car; all three rising is not. // A house load does not follow FTW's orders, and the charger's own reading -// must show the car drawing what that phase shows, so a kettle cannot mark -// a car. A two-phase car moves two phases and is never marked. +// must have changed by what the phases show, so a kettle cannot mark a car +// and a heat pump cannot clear one. A two-phase car moves two phases and is +// never marked. With no reading from before the car drew, as after a Core +// restart, the charger's own draw is the reference: one phase carrying all +// of it and the other two less than a third, which no three- or two-phase +// car can show. const ( onePhaseStepA = 2.0 // the smallest order rise that is a test @@ -41,6 +45,7 @@ type SitePhases struct { type phaseTest struct { at, until time.Time baseline [3]float64 // the meter just before the order took effect + baseDrawA float64 // the charger's draw then, per one phase's voltage fromA float64 // the order per phase before the rise verdict int // 1-3: one phase, that one; -1: three phases; 0: none since time.Time // when the meter began to show the verdict @@ -53,15 +58,23 @@ type carPhases struct { // rising is set once the order has gone above orderA, and before is the // meter read before that first raise went out: a fast charger has // applied it by the next reading, and a fuse ramp climbs 1 A at a time. - rising bool - before [3]float64 - last [3]float64 // the meter read before this tick's command - test *phaseTest - phase int + rising bool + before [3]float64 + beforeDrawA float64 + // last and lastDrawA are the meter and the charger's draw read before + // this tick's command; seen is false until the session has one. + last [3]float64 + lastDrawA float64 + seen bool + test *phaseTest + steady int // the one phase the charger's whole draw shows on, 0 none + since time.Time + phase int } // phaseVerdict reads one meter reading against the baseline of a rise of -// riseA a phase. drawA is the charger's own power over one phase's voltage. +// riseA a phase. drawA is the change in the charger's own power since the +// baseline, over one phase's voltage. func phaseVerdict(amps, baseline [3]float64, riseA, drawA float64) int { var moved [3]float64 k, big := 0, math.Inf(-1) @@ -88,15 +101,40 @@ func phaseVerdict(amps, baseline [3]float64, riseA, drawA float64) int { rose++ } } + sum := moved[0] + moved[1] + moved[2] switch { - case rose == 2: + case !finite(drawA): + return 0 + case rose == 2 && drawA >= 0.6*sum && drawA <= 1.5*sum: return -1 - case quiet == 2 && finite(drawA) && drawA >= 0.6*big && drawA <= 1.5*big: + case quiet == 2 && drawA >= 0.6*big && drawA <= 1.5*big: return k + 1 } return 0 } +// steadyVerdict is the check with no baseline: the phase (1-3) that carries +// all of the charger's draw while the other two carry less than a third of +// it, or 0. drawA is the charger's draw over one phase's voltage. +func steadyVerdict(amps [3]float64, drawA float64) int { + if !finite(drawA) || drawA < 6 { + return 0 + } + phase, quiet := 0, 0 + for i, a := range amps { + switch { + case a >= drawA-1: + phase = i + 1 + case a < drawA/3-1: + quiet++ + } + } + if quiet == 2 { + return phase + } + return 0 +} + // SetSitePhases wires the site meter's phase currents for the one-phase car // check. nil turns the check off. func (c *Controller) SetSitePhases(f func() (SitePhases, bool)) { @@ -146,10 +184,10 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa return } s := &lp.carPhases - previous := s.last - s.last = meter.A + previous, previousDrawA, seen := s.last, s.lastDrawA, s.seen + s.last, s.lastDrawA, s.seen = meter.A, drawA, true if !lp.commandedKnown || lp.commandedPhases != 3 || !finite(lp.commandedW) { - s.test, s.rising = nil, false + s.test, s.rising, s.steady = nil, false, 0 return } orderA := math.Max(0, lp.commandedW) / (3 * voltage) @@ -160,22 +198,25 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa switch { case orderA <= s.orderA: s.orderA, s.rising = orderA, false // A cut is the base of the next rise. - return + case !seen: + s.orderA = orderA // The order was out before the first reading. case !s.rising: - s.rising, s.before = true, previous + s.rising, s.before, s.beforeDrawA = true, previous, previousDrawA } - if orderA-s.orderA < onePhaseStepA { + if !s.rising || orderA-s.orderA < onePhaseStepA { + m.observeSteadyPhase(s, meter.A, drawA, now) return } - s.test = &phaseTest{at: now, until: now.Add(settle + onePhaseHold), baseline: s.before, fromA: s.orderA} - s.orderA, s.rising = orderA, false + s.test = &phaseTest{at: now, until: now.Add(settle + onePhaseHold), baseline: s.before, + baseDrawA: s.beforeDrawA, fromA: s.orderA} + s.orderA, s.rising, s.steady = orderA, false, 0 } t := s.test s.orderA = max(s.orderA, orderA) if meter.At.Before(t.at) { return } - if v := phaseVerdict(meter.A, t.baseline, orderA-t.fromA, drawA); v != t.verdict { + if v := phaseVerdict(meter.A, t.baseline, orderA-t.fromA, drawA-t.baseDrawA); v != t.verdict { t.verdict, t.since = v, now } if t.verdict == 0 || now.Sub(t.since) < onePhaseHold { @@ -185,6 +226,20 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa s.test = nil } +// observeSteadyPhase marks an unmarked car whose charger's whole draw has +// shown on one phase alone for onePhaseHold. Caller holds m.mu. +func (m *Manager) observeSteadyPhase(s *carPhases, amps [3]float64, drawA float64, now time.Time) { + if s.phase != 0 { + return + } + if v := steadyVerdict(amps, drawA); v != s.steady { + s.steady, s.since = v, now + } + if s.steady != 0 && now.Sub(s.since) >= onePhaseHold { + s.phase = s.steady + } +} + // chargerPhaseMaxA is the most current a charger offers on each phase of a // three-phase order: its maximum over three phases, within the site fuse. func chargerPhaseMaxA(cfg Config, site SiteFuse) float64 { diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go index 2b958cd55..d1c468978 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -69,15 +69,20 @@ func TestOnePhaseCarMarkedFromTheMeter(t *testing.T) { if st := state(); st.CarPhases != 1 || st.CarPhase != 1 { t.Fatalf("after 35 s on L1 alone: car phases %d on L%d", st.CarPhases, st.CarPhase) } - // A later rise that shows on all three phases is a three-phase car. + // A rise that shows on all three phases, with the charger's draw rising + // by their sum, is a three-phase car: here one marked by mistake. + threePhase := func(a float64) func(time.Duration) [3]float64 { + return func(time.Duration) [3]float64 { return [3]float64{base[0] + a, base[1] + a, base[2] + a} } + } + run(95*time.Second, 120*time.Second, threePhase(7), 3*1610) order(11040) allThree := func(d time.Duration) [3]float64 { - if d < 140*time.Second { - return [3]float64{base[0] + 7, base[1], base[2]} + if d < 160*time.Second { + return threePhase(7)(d) } - return [3]float64{base[0] + 16, base[1] + 9, base[2] + 9} + return threePhase(16)(d) } - run(95*time.Second, 200*time.Second, allThree, 11040) + run(125*time.Second, 200*time.Second, allThree, 11040) if st := state(); st.CarPhases != 0 { t.Fatalf("a rise on all three phases left the mark on L%d", st.CarPhase) } @@ -131,3 +136,109 @@ func TestOnePhaseCarOrderNamesItsCurrent(t *testing.T) { t.Fatalf("sent %v", payload) } } + +// phaseRig feeds a session's meter and charger readings every 5 s. +type phaseRig struct { + m *Manager + start time.Time +} + +func newPhaseRig() *phaseRig { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040}}) + m.Observe("garage", true, 0, 0, true) + return &phaseRig{m: m, start: time.Date(2026, 10, 22, 3, 0, 0, 0, time.UTC)} +} + +func (r *phaseRig) order(w float64) { + r.m.SetCommanded("garage", w, "plan") + r.m.setCommandedPhases("garage", 3) +} + +func (r *phaseRig) run(from, to time.Duration, amps func(time.Duration) [3]float64, drawW func(time.Duration) float64) { + for d := from; d <= to; d += 5 * time.Second { + at := r.start.Add(d) + r.m.observePhases("garage", SitePhases{A: amps(d), At: at}, drawW(d)/230, 230, time.Minute, at) + } +} + +func (r *phaseRig) carPhases() int { st, _ := r.m.State("garage"); return st.CarPhases } + +// Review M1: the charger's draw is compared as a change. A first test spoilt +// by 2 A on L2 leaves a car already drawing 6 A on L1; its next 2 A rise, +// on L1 alone with the charger's draw up by 2 A, marks it. +func TestOnePhaseCarMarkedOnALaterRise(t *testing.T) { + if v := phaseVerdict([3]float64{12, 2, 2}, [3]float64{8, 2, 2}, 4, 4); v != 1 { + t.Fatalf("a 4 A rise on L1 with the car up by 4 A: verdict %d", v) + } + r := newPhaseRig() + still := func(a [3]float64) func(time.Duration) [3]float64 { return func(time.Duration) [3]float64 { return a } } + draw := func(w float64) func(time.Duration) float64 { return func(time.Duration) float64 { return w } } + r.order(0) + r.run(0, 10*time.Second, still([3]float64{2, 2, 2}), draw(0)) + r.order(4140) + r.run(15*time.Second, 35*time.Second, still([3]float64{2, 2, 2}), draw(0)) + r.run(40*time.Second, 200*time.Second, still([3]float64{8, 4, 2}), draw(1380)) + if r.carPhases() != 0 { + t.Fatal("marked through 2 A on L2") + } + r.order(8 * 690) + r.run(205*time.Second, 230*time.Second, still([3]float64{8, 4, 2}), draw(1380)) + r.run(235*time.Second, 300*time.Second, still([3]float64{10, 4, 2}), draw(1840)) + if r.carPhases() != 1 { + t.Fatal("a 2 A rise on L1 alone, followed by the charger, did not mark the car") + } +} + +// Review M2: a heat pump starting at 2 A a phase during a 2 A rise moves all +// three phases, but the charger's draw rose by 2 A, not 8: the mark stays. +func TestHeatPumpDoesNotClearAOnePhaseCar(t *testing.T) { + r := newPhaseRig() + r.m.byID["garage"].carPhases = carPhases{orderA: 6, phase: 1} + r.order(6 * 690) + base := [3]float64{8, 2, 2} + r.run(0, 10*time.Second, func(time.Duration) [3]float64 { return base }, func(time.Duration) float64 { return 1380 }) + r.order(8 * 690) + r.run(15*time.Second, 200*time.Second, func(d time.Duration) [3]float64 { + a := base + if d >= 20*time.Second { + a = [3]float64{a[0] + 2, a[1] + 2, a[2] + 2} + } + if d >= 60*time.Second { + a[0] += 2 + } + return a + }, func(d time.Duration) float64 { + if d >= 60*time.Second { + return 8 * 230 + } + return 6 * 230 + }) + if r.carPhases() != 1 { + t.Fatal("a heat pump starting during a rise cleared the one-phase car") + } +} + +// Review M3: after a Core restart the order is out and the car draws before +// the first reading. With no baseline, a one-phase car still shows its whole +// draw on one phase and less than a third on the others; a three-phase car +// never does. +func TestOnePhaseCarMarkedAfterARestart(t *testing.T) { + for _, c := range []struct { + name string + amps [3]float64 + drawW float64 + want int + }{ + {"one-phase car at 16 A on L1, house 3/1/1 A", [3]float64{19, 1, 1}, 3680, 1}, + {"three-phase car at 16 A, house 3/1/1 A", [3]float64{19, 17, 17}, 11040, 0}, + {"two-phase car at 16 A, house 3/1/1 A", [3]float64{19, 17, 1}, 7360, 0}, + } { + r := newPhaseRig() + r.order(11040) + r.run(0, 60*time.Second, func(time.Duration) [3]float64 { return c.amps }, func(time.Duration) float64 { return c.drawW }) + if got := r.carPhases(); got != c.want { + t.Errorf("%s: car phases %d, want %d", c.name, got, c.want) + } + } +} From e02cda4cad44b56417e81c0255f596a6dcc410a0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:46:13 +0200 Subject: [PATCH 4/5] fix(api): judge a one-phase car after limits, setpoint and waiting "Charging on one phase" ran before the limit guard, so a fuse limit, import limit, fuse cooldown, stale site meter, changed setpoint or a reading older than the order all read as limited/one_phase. The branch now runs after the waiting check; those verdicts come first, and a draw beyond tolerance is judged as for any car. The field matrix judged the car's draw against the full order and let only one_phase excuse a third of it. It now judges the draw against the command's expected_w, as Core does, so a marked car under a fuse limit reads fuse_limit without failing the verdict check. The known list does not change. Co-Authored-By: Claude Opus 5.5 --- go/internal/api/api_control_feedback.go | 15 +++-- go/internal/api/api_one_phase_order_test.go | 71 +++++++++++++++++++++ go/internal/control/sitesim_field_test.go | 32 ++++++---- 3 files changed, 100 insertions(+), 18 deletions(-) create mode 100644 go/internal/api/api_one_phase_order_test.go diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index ebacb0e67..1ed31dd9d 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -460,13 +460,6 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("warning", "no_power_response", "warning") return } - // A one-phase car drawing a third of a three-phase order does what it - // can: the order is its current per phase, on its one phase. - if f.ChargingPhase != "" && commanded && cmd.Result == "accepted" && f.SentW != nil && *f.SentW > 100 && - f.ActualW != nil && math.Abs(*f.ActualW-*f.SentW) <= telemetry.ControlToleranceW(*f.SentW) { - set("limited", "one_phase", "info") - return - } if f.RequestedA != nil && f.DeviceLimitA != nil && *f.RequestedA > *f.DeviceLimitA+0.5 { set("limited", "device_limit", "warning") return @@ -510,6 +503,14 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("waiting", "waiting_response", "info") return } + // A one-phase car drawing a third of a three-phase order does what it + // can: the order is its current per phase, on its one phase. A limit, a + // changed setpoint or a response still due says more, and comes first. + if f.ChargingPhase != "" && cmd.Result == "accepted" && f.SentW != nil && *f.SentW > 100 && + f.ActualW != nil && math.Abs(*f.ActualW-*f.SentW) <= telemetry.ControlToleranceW(*f.SentW) { + set("limited", "one_phase", "info") + return + } if f.SentW == nil || f.ActualW == nil { set("unknown", "response_unknown", "info") return diff --git a/go/internal/api/api_one_phase_order_test.go b/go/internal/api/api_one_phase_order_test.go new file mode 100644 index 000000000..9e153204b --- /dev/null +++ b/go/internal/api/api_one_phase_order_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// For a one-phase car the command's power is a third of the order. +// "Charging on one phase" explains a draw that matches it; a limit, a +// changed setpoint or a response still due says more and comes first, and +// a draw beyond tolerance is judged as for any car. +func TestOnePhaseVerdictComesAfterLimits(t *testing.T) { + now := time.Now() + connected := true + for _, tc := range []struct { + name string + marked bool + core string + sent, actual float64 + requestedA, offeredA float64 + mismatch, over bool // since 12 minutes + gap float64 + observedBeforeCommand bool + readbackMismatch bool + state, reason string + }{ + {"fuse-limited order, car takes its third", true, "fuse_limit", 1380, 1380, 6, 6, false, false, 0, false, false, "limited", "fuse_limit"}, + {"same, unmarked car taking the order", false, "fuse_limit", 4140, 4140, 6, 6, false, false, 0, false, false, "limited", "fuse_limit"}, + {"import-limited, car takes its third", true, "import_limit", 1610, 1600, 7, 7, false, false, 0, false, false, "limited", "import_limit"}, + {"fuse cooldown, car takes its third", true, "fuse_cooldown", 1380, 1380, 6, 6, false, false, 0, false, false, "limited", "fuse_cooldown"}, + {"meter stale, car takes its third", true, "site_meter_stale", 1380, 1380, 6, 6, false, false, 0, false, false, "limited", "site_meter_stale"}, + {"setpoint changed by another app, draw matches", true, "", 3680, 3680, 16, 16, false, false, 0, false, true, "warning", "setpoint_changed"}, + {"reading older than the command", true, "", 2300, 2300, 10, 10, false, false, 0, true, false, "waiting", "waiting_response"}, + {"plan order, car takes its third", true, "", 3680, 3650, 16, 16, false, false, 0, false, false, "limited", "one_phase"}, + {"overdraw: charger ignores a cut 16->10 A", true, "", 2300, 3680, 10, 16, true, true, 1380, false, false, "warning", "power_above_target"}, + {"overdraw under fuse_limit", true, "fuse_limit", 2300, 3680, 10, 16, true, true, 1380, false, false, "warning", "power_above_target"}, + {"shortfall: car takes 1.5 of 3.7 kW", true, "", 3680, 1500, 16, 16, true, false, 2180, false, false, "warning", "power_below_target"}, + {"wrong mark: 3p car draws the full order", true, "", 3680, 11040, 16, 16, true, true, 7360, false, false, "warning", "power_above_target"}, + {"overdraw inside tolerance (+300 W on 3.68 kW)", true, "", 3680, 3980, 16, 16, true, true, 300, false, false, "limited", "one_phase"}, + } { + t.Run(tc.name, func(t *testing.T) { + c := telemetry.CommandEvidence{Result: "accepted", Since: now.Add(-15 * time.Minute), LastObservation: now} + if tc.mismatch { + c.PowerMismatchSince = now.Add(-12 * time.Minute) + c.LastGapW = &tc.gap + } else { + c.PowerMatchSince = now.Add(-time.Minute) + } + if tc.over { + c.PowerOverSince = now.Add(-12 * time.Minute) + } + if tc.readbackMismatch { + c.ReadbackMismatchSince = now.Add(-time.Minute) + } + f := ControlFeedback{Kind: "ev", SentW: watts(tc.sent), ActualW: watts(tc.actual), ReadbackW: watts(tc.sent), + ObservedAtMs: now.UnixMilli(), Response: "device_reported", RequestedA: watts(tc.requestedA), OfferedA: watts(tc.offeredA)} + if tc.observedBeforeCommand { + f.ObservedAtMs = now.Add(-20 * time.Minute).UnixMilli() + } + if tc.marked { + f.ChargingPhase = "L1" + } + classifyControlFeedback(&f, c, true, true, tc.core, &connected, now) + if f.State != tc.state || f.Reason != tc.reason { + t.Fatalf("%s/%s, want %s/%s", f.State, f.Reason, tc.state, tc.reason) + } + }) + } +} diff --git a/go/internal/control/sitesim_field_test.go b/go/internal/control/sitesim_field_test.go index bf2ef5fa4..c4294011a 100644 --- a/go/internal/control/sitesim_field_test.go +++ b/go/internal/control/sitesim_field_test.go @@ -402,6 +402,7 @@ type fieldRun struct { deliveredWh float64 orderW float64 // the order last sent, and since when orderAt time.Time + expectW float64 // the draw it asks for: expected_w for a one-phase car, else the order mismatchSince time.Time // the car drawing against an order older than its response time drawW float64 // the car's draw, and when it last changed drawChangedAt time.Time @@ -470,11 +471,12 @@ func (f *fieldRun) limitA(s *simSite) float64 { return s.p.Fuse.RatingA - evPhas // send records the command as the driver registry does and checks a cut. func (f *fieldRun) send(ctx context.Context, s *simSite, driver string, payload []byte) error { var cmd struct { - Action string `json:"action"` - PowerW float64 `json:"power_w"` + Action string `json:"action"` + PowerW float64 `json:"power_w"` + ExpectedW *float64 `json:"expected_w"` } - if err := json.Unmarshal(payload, &cmd); err == nil && cmd.Action == "ev_set_current" && driver == s.lp.DriverName && - cmd.PowerW < s.offerW-1 && !s.p.Offline("evse") { + ours := json.Unmarshal(payload, &cmd) == nil && cmd.Action == "ev_set_current" && driver == s.lp.DriverName + if ours && cmd.PowerW < s.offerW-1 && !s.p.Offline("evse") { // Surplus follows the export, an outside step; its cuts are not judged. if st, _ := s.mgr.State(s.lp.ID); st.CommandedReason == "fuse_limit" && !s.surplus { f.checkCut(s, cmd.PowerW) @@ -493,8 +495,15 @@ func (f *fieldRun) send(ctx context.Context, s *simSite, driver string, payload result = "failed" } s.store.CompleteCommand(evidence, result) - if s.offerW != f.orderW { - f.orderW, f.orderAt = s.offerW, s.p.Now + expectW := f.expectW + if ours && err == nil { + expectW = s.offerW + if cmd.ExpectedW != nil { + expectW = *cmd.ExpectedW + } + } + if s.offerW != f.orderW || expectW != f.expectW { + f.orderW, f.expectW, f.orderAt = s.offerW, expectW, s.p.Now } return err } @@ -610,7 +619,7 @@ func (f *fieldRun) check(s *simSite) { f.fail(s, failOnePhase, "%s put the three-phase charger on one phase at %.0f A", f.c.use, p.EVSE.OfferA) } up := s.downUntil.IsZero() - if up && p.Car.Plugged && now.Sub(f.orderAt) >= f.response() && math.Abs(p.EVSE.DrawW-f.orderW) > telemetry.ControlToleranceW(f.orderW) { + if up && p.Car.Plugged && now.Sub(f.orderAt) >= f.response() && math.Abs(p.EVSE.DrawW-f.expectW) > telemetry.ControlToleranceW(f.expectW) { if f.mismatchSince.IsZero() { f.mismatchSince = now } @@ -631,12 +640,13 @@ func (f *fieldRun) check(s *simSite) { } } // Core waits its response time for an order to take hold, then two - // minutes of measured disagreement (srcfl/ftw#1795). A one-phase draw - // may read limited instead (srcfl/ftw#1796). + // minutes of measured disagreement (srcfl/ftw#1795). A car marked + // one-phase is judged against a third of a three-phase order, the + // expected_w Core sends with it (srcfl/ftw#1801). if f.verdict != nil && !f.mismatchSince.IsZero() && now.Sub(f.mismatchSince) >= 2*time.Minute+30*time.Second { if status, reason := f.verdict(); status != "not_following" && reason != "one_phase" { - f.fail(s, failVerdict, "the car draws %.0f W against a %.0f W order (%v), the verdict says %s/%s", - p.EVSE.DrawW, f.orderW, now.Sub(f.orderAt).Round(time.Second), status, reason) + f.fail(s, failVerdict, "the car draws %.0f W against an expected %.0f W (%v), the verdict says %s/%s", + p.EVSE.DrawW, f.expectW, now.Sub(f.orderAt).Round(time.Second), status, reason) } } } From af1288cfd0b3e5d5a717a37ddd836f3fc518e1c6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:59:41 +0200 Subject: [PATCH 5/5] test(control): rewrite the field matrix's known list on #1802 On master with #1802, the 39 one-phase car energy rows (D) and the one energy row labelled A pass with this branch; their lines and labels go. No row is new. Co-Authored-By: Claude Opus 5.5 --- .../control/testdata/sitesim_field_known.txt | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 006f47c31..f3a4a2004 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -3,14 +3,10 @@ # and when a row fails that is not listed. FTW_FIELD_KNOWN=write rewrites # this file from a run, keeps each label and marks unlisted failures "new". # -# A srcfl/ftw#1799: room limits rises only; the split never cuts a -# running order. # B srcfl/ftw#1799: planned charging and Charge now run on three # phases; the planner gets steps a three-phase order can deliver. # #1795 srcfl/ftw#1795: a charger drawing against its order reads not # following. -# D after beta.2: plan a one-phase car at the rate it takes -# (srcfl/ftw#1796, srcfl/ftw#1792). # export srcfl/ftw#1782, draft: an exporting phase counts as import, so # surplus never finds room to start. # readback no fix yet: surplus raises again before the charger applied the @@ -20,10 +16,6 @@ # hold3p no fix yet: the app sends Charge now as 3p for an auto # loadpoint, and on a charger whose table stops below 4,140 W # Core floors the order to 0 W (fuse_limit). -help1022/20A/auto/own/cloud60s/car1p/2A/planned energy D -help1022/20A/auto/own/easee120s/car1p/2A/planned energy A -help1022/20A/auto/own/easee40s/car1p/2A/planned energy D -help1022/20A/auto/own/fast2s/car1p/2A/planned energy D pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief @@ -33,42 +25,6 @@ pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief probe/25A/auto/1p-3/easee40s/car3p/balanced/chargenow+charger1p32A idle hold3p probe/25A/auto/1p-3/fast2s/car3p/balanced/chargenow+charger1p32A idle hold3p probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback -sweep/16A/3p/3p/easee40s/car1p/L1/planned energy D -sweep/16A/3p/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/none/easee40s/car1p/L1/planned energy D -sweep/16A/3p/none/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/16A/auto/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/auto/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/none/easee40s/car1p/L1/planned energy D -sweep/16A/auto/none/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/16A/empty/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/empty/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/none/easee40s/car1p/L1/planned energy D -sweep/16A/empty/none/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/3p/easee40s/car1p/L1/planned energy D -sweep/25A/3p/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/none/easee40s/car1p/L1/planned energy D -sweep/25A/3p/none/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/25A/auto/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/auto/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/none/easee40s/car1p/L1/planned energy D -sweep/25A/auto/none/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/25A/empty/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/empty/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/none/easee40s/car1p/L1/planned energy D -sweep/25A/empty/none/easee40s/car1p/balanced/planned energy D timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export