From 8ac9db74f24e8fbebe43b238b31510b522636e1c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 07:28:30 +0200 Subject: [PATCH] fix(loadpoint): a charger on one phase under a three-phase order is seen, and planned at its rate Field report, v0.155.0-beta.1, Easee Cloud: a 7.6 kW three-phase plan order drawn as 3.13 kW on one phase all night, about 13 of 24 kWh. With a schedule and an empty phase mode Core sends auto and counts it on three phases above 3,680 W; the Easee driver splits auto at the fuse's current on one phase (8,050 W at 35 A) and locks phaseMode 1. An Easee can also stay on one phase. The loadpoint controller now compares the charger's fresh power with the site meter's phases under a three-phase order. Two minutes with all of the current on one phase and less than a third on the others mark the session: the plan counts the rate delivered there (LimitPower), the controller repeats the order that delivered it while a slot has energy left, and the verdict is Limited, "Charging on one phase", with the phase and its current. The fuse limit is unchanged. The closed-loop sim splits auto as the Easee driver does and gains a charger stuck on one phase. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-one-phase.md | 5 + go/cmd/ftw/main.go | 5 + go/internal/api/api_control_feedback.go | 28 ++++- go/internal/api/api_ev_one_phase_test.go | 80 +++++++++++++ go/internal/control/fuse_heat.go | 19 +++ go/internal/control/sitesim_test.go | 44 +++++++ go/internal/loadpoint/controller.go | 8 ++ go/internal/loadpoint/deadline_need.go | 15 ++- go/internal/loadpoint/loadpoint.go | 15 +++ go/internal/loadpoint/one_phase.go | 141 +++++++++++++++++++++++ go/internal/loadpoint/one_phase_test.go | 117 +++++++++++++++++++ go/internal/sitesim/sitesim.go | 4 + web/control-feedback.js | 3 + web/control-feedback.test.mjs | 6 + 14 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 .changeset/ev-one-phase.md create mode 100644 go/internal/api/api_ev_one_phase_test.go create mode 100644 go/internal/loadpoint/one_phase.go create mode 100644 go/internal/loadpoint/one_phase_test.go diff --git a/.changeset/ev-one-phase.md b/.changeset/ev-one-phase.md new file mode 100644 index 000000000..1d9320274 --- /dev/null +++ b/.changeset/ev-one-phase.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When a charger draws on one phase although FTW ordered three, FTW now says so and plans with what the charger delivers. In 0.155.0 an Easee charger on one phase took 3.13 kW all night against a 7.6 kW plan order: about 13 of the 24 planned kWh arrived, and the plan kept promising the rest. FTW now compares the charger's power with the site meter's phases. When one phase carries all of it and the other two less than a third, for two minutes, the charger panel says "Charging on one phase" with the phase and its current. For the rest of that charging session the plan counts the rate the charger delivered, and FTW repeats the order that got that rate. Two minutes with the draw on all three phases, or unplugging, ends it. The fuse limit is unchanged: it still reads only the site meter and FTW's own orders. 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 ca84538ed..117a9307b 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" @@ -55,9 +56,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) that carries a charger's + // draw under a three-phase order, and ChargingPhaseA its current there. + ChargingPhase string `json:"charging_phase,omitempty"` + ChargingPhaseA *float64 `json:"charging_phase_a,omitempty"` + SinceMs int64 `json:"since_ms,omitempty"` + CommandAtMs int64 `json:"command_at_ms,omitempty"` + ObservedAtMs int64 `json:"observed_at_ms,omitempty"` } type feedbackReading struct { @@ -208,6 +213,14 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { fresh = false f.ActualW = nil } + if lp.OnePhase > 0 && f.ActualW != nil { + voltage := lp.VoltageV + if voltage <= 0 { + voltage = 230 + } + f.ChargingPhase = fmt.Sprintf("L%d", lp.OnePhase) + f.ChargingPhaseA = watts(*f.ActualW / voltage) + } } // Evidence describes measured power, not target fulfilment. A device // delivering 4.4 kW against a 5 kW command can still be site-confirmed. @@ -341,7 +354,7 @@ func setControlStatus(f *ControlFeedback) { status = "limited" case "import_limit": status = "limited" - case "device_limit", "offered_current_lower": + case "device_limit", "offered_current_lower", "one_phase": status, severity = "limited", "warning" case "setpoint_changed", "power_below_target", "power_above_target", "power_wrong_direction", "no_power_response", "power_while_idle": @@ -428,6 +441,13 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("warning", "no_power_response", "warning") return } + // The site meter shows a three-phase order drawn on one phase. That + // explains a draw below the order, never one above it. + if f.ChargingPhase != "" && commanded && cmd.Result == "accepted" && f.SentW != nil && *f.SentW > 100 && + f.ActualW != nil && *f.ActualW <= *f.SentW+telemetry.ControlToleranceW(*f.SentW) { + set("limited", "one_phase", "warning") + 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_test.go b/go/internal/api/api_ev_one_phase_test.go new file mode 100644 index 000000000..3c0651f87 --- /dev/null +++ b/go/internal/api/api_ev_one_phase_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "context" + "fmt" + "math" + "testing" + "testing/synctest" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Field report, 22 Oct 2026: a three-phase plan order an Easee served on L1 +// at 3.13 kW all night. The loadpoint controller sees it from the site +// meter, and the verdict names the phase and its current instead of a +// charger that does not follow. +func TestThreePhaseOrderDrawnOnOnePhaseIsLimited(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tel := telemetry.NewStore() + tel.DriverHealthMut("easee") + var steps []float64 + for a := 0.0; a <= 16; a++ { + if a == 0 || a >= 6 { + steps = append(steps, a*690) + } + } + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, AllowedStepsW: steps}}) + slot := time.Now() + plan := func(time.Time) (loadpoint.Directive, bool) { + return loadpoint.Directive{SlotStart: slot, SlotEnd: slot.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"garage": 7590}}, true + } + sample := func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true, PowerW: 3130, PowerAt: time.Now()}, true + } + send := func(_ context.Context, driver string, payload []byte) error { + c := tel.BeginCommand(driver, payload, time.Now()) + tel.CompleteCommand(c, "accepted") + return nil + } + ctrl := loadpoint.NewController(mgr, plan, sample, send) + ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 25, Voltage: 230, PhaseCnt: 3}) + ctrl.SetSitePhases(func() (loadpoint.SitePhases, bool) { + return loadpoint.SitePhases{A: [3]float64{14.4, .75, .75}, At: time.Now()}, true + }) + srv := New(&Deps{Tel: tel, Loadpoints: mgr}) + changedAt := time.Now() + var f ControlFeedback + for range 6 * 60 / 5 { + ctrl.Tick(context.Background(), time.Now()) + tel.Update("easee", telemetry.DerEV, 3130, 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 != "limited" || f.Reason != "one_phase" || f.Severity != "warning" || f.ChargingPhase != "L1" || + f.ChargingPhaseA == nil || math.Abs(*f.ChargingPhaseA-13.6) > .1 { + a := math.NaN() + if f.ChargingPhaseA != nil { + a = *f.ChargingPhaseA + } + t.Fatalf("3.13 kW on L1 under a %.0f W order reads %s/%s/%s, phase %q at %.1f A", *f.SentW, f.Status, f.Reason, f.Severity, f.ChargingPhase, a) + } + }) +} + +// One phase never hides a charger that draws more than it is told. +func TestOnePhaseDoesNotExplainADrawAboveTheOrder(t *testing.T) { + sent, actual := 0.0, 3130.0 + f := ControlFeedback{Kind: "ev", SentW: &sent, ActualW: &actual, ChargingPhase: "L1", Response: "device_reported"} + cmd := telemetry.CommandEvidence{Result: "accepted"} + connected := true + classifyControlFeedback(&f, cmd, true, true, "fuse_limit", &connected, time.Now()) + if f.Reason == "one_phase" { + t.Fatalf("a draw against a stop read as one phase: %+v", f.Reason) + } +} diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 305d9f5ab..a373c3cec 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, as +// magnitudes, for the loadpoints' one-phase 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 d94c99bb1..56622f454 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -95,6 +95,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 @@ -142,6 +143,7 @@ func (s *simSite) send(_ context.Context, driver string, payload []byte) error { PowerW float64 `json:"power_w"` PhaseMode string `json:"phase_mode"` SplitW float64 `json:"phase_split_w"` + MaxA float64 `json:"max_amps_per_phase"` } if err := json.Unmarshal(payload, &cmd); err != nil { return err @@ -161,6 +163,11 @@ func (s *simSite) send(_ context.Context, driver string, payload []byte) error { } s.offerW = cmd.PowerW s.commands = append(s.commands, simCommand{s.p.Now, "evse", cmd.PowerW}) + // Without a split the Easee driver splits auto at the fuse's current + // on one phase (easee_cloud.lua pick_phases). + if cmd.SplitW <= 0 && cmd.MaxA > 0 { + cmd.SplitW = s.p.Voltage * cmd.MaxA + } s.p.EVSE.Command(s.p.Now, cmd.PowerW, cmd.PhaseMode, cmd.SplitW, s.p.Voltage) return nil } @@ -581,10 +588,47 @@ func simScenarios() []simScenario { } return nil }}}}, + // Field report 22 Oct 2026: a three-phase order an Easee served on L1 + // at 3.13 kW, about 13.6 A, all night. The plan counted the order. + {name: "charger stuck on one phase", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.EVSE.StuckPhases, p.EVSE.Delay, p.Car.MaxW = houseA(1, .5, .5), 1, time.Minute, 3130 + }, + expect: []simExpect{{6 * time.Minute, onePhaseSeen(1, 3130)}}}, + // Core counts an auto order above 3,680 W on three phases and sends no + // split; the Easee driver then splits at the fuse's current on one + // phase, 8,050 W at 35 A, and puts 7.6 kW on one phase. + {name: "Easee resolves auto to one phase", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW, p.EVSE.Delay, p.Car.MaxW = 35, houseA(1, .5, .5), time.Minute, 3130 + }, + lp: func(c *loadpoint.Config) { c.PhaseMode, c.MaxChargeW = "auto", 7590 }, + expect: []simExpect{{6 * time.Minute, onePhaseSeen(1, 3130)}}}, + // A three-phase car at its minimum beside a heavy L1 and solar export + // on the other two is not a one-phase draw. + {name: "three-phase car, heavy L1, export", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Car.MaxW = houseA(13, .5, .5), -3000, 4140 }, + expect: []simExpect{{10*time.Minute - time.Second, onePhaseSeen(0, 0)}}}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } +// onePhaseSeen checks that the loadpoint sees its draw on phase, at about +// rateW, and that the plan counts that rate; phase 0 is no one-phase draw. +func onePhaseSeen(phase int, rateW float64) func(*simSite) error { + return func(s *simSite) error { + st, _ := s.mgr.State(s.lp.ID) + if st.OnePhase != phase || math.Abs(st.OnePhaseW-rateW) > 50 { + return fmt.Errorf("one-phase draw: phase %d at %.0f W (order %.0f W), want phase %d at %.0f W; car draws %.0f W on %d phases", + st.OnePhase, st.OnePhaseW, st.OnePhaseOrderW, phase, rateW, s.p.EVSE.DrawW, s.p.EVSE.DrawPhases) + } + if _, maxW, _ := loadpoint.LimitPower(st, st.AllowedStepsW); phase > 0 && maxW != st.OnePhaseW { + return fmt.Errorf("the plan counts %.0f W, not the %.0f W delivered on one phase", maxW, st.OnePhaseW) + } + return nil + } +} + // runSimScenario runs sc from start and returns the site, with the first // broken invariant or expectation in failure. func runSimScenario(sc simScenario, start time.Time) *simSite { diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 07bc69f30..7ff23c5d9 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 check. + sitePhases func() (SitePhases, bool) // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 @@ -1318,6 +1320,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.observeOnePhase(cfg, sample, now) c.restoreManualHoldForSession(cfg.ID) c.evaluateBatteryBoost(cfg.ID, now, sample.Connected, dispatchAllowed && !sample.PowerUnavailable) } @@ -2493,6 +2496,11 @@ func (c *Controller) computeCommandForBudget(now time.Time, lpCfg Config, curren // A duty plan commands its legal on-power until the Wh budget is spent. wantW = maxW } + // The plan counts a charger on one phase at the rate it delivered. Repeat + // the order that delivered it until the slot's energy is spent. + if st, known := c.manager.State(lpCfg.ID); known && wantW > 0 && st.OnePhase > 0 && st.OnePhaseOrderW > 0 { + wantW = min(st.OnePhaseOrderW, maxW) + } // Joint fuse allocator (dispatch.go) caps EV demand when battery + EV // would together bust the fuse. Honour it before snapping to the // charger's discrete steps so the snap chooses a level under the cap. diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index 73f88e0eb..7c55c0ffa 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -2,6 +2,7 @@ package loadpoint import ( "math" + "slices" ) // DeadlineNeed is the remaining AC energy and legal on-power of a saved goal, @@ -43,8 +44,20 @@ func DeadlineNeed(st State) (energyWh, powerW float64, ok bool) { // lower of the charger's and the car's maximum, and the charger's steps up to // it. When the charger's lowest step is above the car's maximum it keeps that // one step: the car takes what it can, and a plan with no step would never -// charge. +// charge. A charger seen on one phase under a three-phase order delivers its +// one-phase rate whatever step is ordered, so the plan counts that rate; the +// controller repeats the order that delivered it. func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { + if st.OnePhase > 0 && finite(st.OnePhaseW) && st.OnePhaseW > 0 { + rate := st.OnePhaseW + if len(steps) == 0 { + return rate, rate, nil + } + if slices.Contains(steps, 0) { + out = append(out, 0) + } + return rate, rate, append(out, rate) + } minW, maxW = st.MinChargeW, st.MaxChargeW if st.CarMaxW <= 0 || (maxW > 0 && st.CarMaxW >= maxW) { return minW, maxW, steps diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 765197a6c..ed5f20824 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:"-"` + // OnePhase is the phase (1-3) that carries all of this session's draw + // under a three-phase order, zero when it is not seen. OnePhaseW is the + // most it delivered there and OnePhaseOrderW the order that got it. + // The plan counts that rate (LimitPower). + OnePhase int `json:"-"` + OnePhaseW float64 `json:"-"` + OnePhaseOrderW float64 `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 + // onePhase is this session's evidence of a three-phase order drawn on + // one phase (one_phase.go). + onePhase onePhaseState 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.onePhase = onePhaseState{} } if !pluggedIn { // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} lp.chargingDeclined = false + lp.onePhase = onePhaseState{} if lp.capacityFromCar { // The car that measured its own capacity left with its // session — the next one may be different. @@ -1368,6 +1380,9 @@ func (lp *loadpointRuntime) snapshot() State { CommandedReason: lp.commandedReason, CommandedKnown: lp.commandedKnown, CommandedPhases: lp.commandedPhases, + OnePhase: lp.onePhase.phase, + OnePhaseW: lp.onePhase.rateW, + OnePhaseOrderW: lp.onePhase.orderW, } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/loadpoint/one_phase.go b/go/internal/loadpoint/one_phase.go new file mode 100644 index 000000000..698664d38 --- /dev/null +++ b/go/internal/loadpoint/one_phase.go @@ -0,0 +1,141 @@ +package loadpoint + +import ( + "math" + "time" +) + +// A charger told to use three phases may still draw on one: an Easee whose +// phase mode stays on 1p, or a driver that resolves auto to one phase at a +// split Core does not use. The fuse limit reads the site meter and holds +// either way. The plan does not: it counts the order's watts, and the car +// takes what one phase gives. The site meter shows where the draw lands: +// all of the charger's current on one phase, less than a third of it on the +// other two. Two minutes of aligned readings say so for the session, and the +// rate the charger delivered becomes the plan's rate. Two minutes with every +// phase carrying its third end it. + +const ( + // onePhaseMinW keeps a third of the draw clear of a small house load. + onePhaseMinW = 2000.0 + onePhaseMarginA = 1.0 + onePhaseConfirm = 2 * time.Minute + // onePhaseAlign is how far apart the charger's and the meter's readings + // may be, and how old the meter's may be. + onePhaseAlign = 10 * time.Second + // onePhaseGap ends a run of readings that has a hole in it. + onePhaseGap = 30 * time.Second +) + +// SitePhases is the site meter's current on each phase, as magnitudes, and +// when it read them. +type SitePhases struct { + A [3]float64 + At time.Time +} + +// onePhaseDraw says where a charger's draw lands: the phase (1-3) that +// carries all of it while the other two carry less than a third, -1 when +// every phase carries at least a third, or 0 when the meter cannot tell. +func onePhaseDraw(drawW, voltage float64, amps [3]float64) int { + if !finite(drawW) || drawW < onePhaseMinW || !finite(voltage) || voltage <= 0 { + return 0 + } + full, third := drawW/voltage-onePhaseMarginA, drawW/(3*voltage)-onePhaseMarginA + phase, below := 0, 0 + for i, a := range amps { + if !finite(a) { + return 0 + } + a = math.Abs(a) + if a >= full { + phase = i + 1 + } + if a < third { + below++ + } + } + switch { + case phase > 0 && below == 2: + return phase + case below == 0: + return -1 + } + return 0 +} + +// onePhaseState is one session's evidence. rateW is the most the charger +// delivered on one phase and orderW the order in force when it did. +type onePhaseState struct { + since, at time.Time // the current run of one-phase readings + clearSince, clearAt time.Time // the current run of three-phase readings + phase int + rateW, orderW float64 +} + +// SetSitePhases wires the site meter's phase currents for the one-phase +// check. nil turns the check off. +func (c *Controller) SetSitePhases(f func() (SitePhases, bool)) { + if c != nil { + c.sitePhases = f + } +} + +// observeOnePhase compares a charger's fresh draw under a three-phase order +// with the site meter's phases read at the same time. +func (c *Controller) observeOnePhase(cfg Config, sample EVSample, now time.Time) { + if c.sitePhases == nil || !sample.Connected || sample.PowerUnavailable || sample.PowerAt.IsZero() { + return + } + site := c.siteFuse() + st, ok := c.manager.State(cfg.ID) + if !ok || !st.CommandedKnown || st.CommandedW <= 0 || st.CommandedPhases != 3 || site.Phases() != 3 { + return + } + meter, ok := c.sitePhases() + if !ok || now.Sub(meter.At) > onePhaseAlign || meter.At.Sub(sample.PowerAt).Abs() > onePhaseAlign { + return + } + voltage := site.Voltage + if !finite(voltage) || voltage <= 0 { + voltage = 230 + } + c.manager.observeOnePhase(cfg.ID, onePhaseDraw(sample.PowerW, voltage, meter.A), sample.PowerW, st.CommandedW, now) +} + +func (m *Manager) observeOnePhase(id string, phase int, drawW, orderW float64, now time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn { + return + } + s := &lp.onePhase + switch { + case phase > 0: + s.clearSince, s.clearAt = time.Time{}, time.Time{} + if s.since.IsZero() || now.Sub(s.at) > onePhaseGap { + s.since = now + } + s.at = now + if now.Sub(s.since) < onePhaseConfirm { + return + } + if s.phase == 0 || drawW > s.rateW { + s.rateW, s.orderW = drawW, orderW + } + s.phase = phase + case phase < 0: + s.since, s.at = time.Time{}, time.Time{} + if s.phase == 0 { + return + } + if s.clearSince.IsZero() || now.Sub(s.clearAt) > onePhaseGap { + s.clearSince = now + } + s.clearAt = now + if now.Sub(s.clearSince) >= onePhaseConfirm { + *s = onePhaseState{} + } + } +} diff --git a/go/internal/loadpoint/one_phase_test.go b/go/internal/loadpoint/one_phase_test.go new file mode 100644 index 000000000..02b18ca93 --- /dev/null +++ b/go/internal/loadpoint/one_phase_test.go @@ -0,0 +1,117 @@ +package loadpoint + +import ( + "context" + "slices" + "testing" + "time" +) + +func TestOnePhaseDraw(t *testing.T) { + for _, c := range []struct { + name string + drawW float64 + amps [3]float64 + want int + }{ + {"field case: 3.13 kW on L1", 3130, [3]float64{14.4, .75, .75}, 1}, + {"on L3, the others exporting", 3130, [3]float64{-2, -2, 14}, 3}, + {"export hides part of the car's current", 3130, [3]float64{-2, -2, 12}, 0}, + {"three phases", 3130, [3]float64{5.3, 5.3, 5.3}, -1}, + {"too small to tell", 1500, [3]float64{7.5, .5, .5}, 0}, + {"one phase, house load on L2", 3130, [3]float64{14.4, 4.6, .7}, 0}, + {"three-phase minimum, heavy L1", 4140, [3]float64{19, 6.5, 6.5}, -1}, + } { + if got := onePhaseDraw(c.drawW, 230, c.amps); got != c.want { + t.Errorf("%s: got %d, want %d", c.name, got, c.want) + } + } +} + +func TestOnePhaseHoldsForTheSession(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040}}) + m.Observe("garage", true, 3130, 0, true) + start := time.Now() + seen := func(phase int, from, to time.Duration, drawW float64) { + for d := from; d <= to; d += 5 * time.Second { + m.observeOnePhase("garage", phase, drawW, 7590, start.Add(d)) + } + } + state := func() State { st, _ := m.State("garage"); return st } + + seen(1, 0, 115*time.Second, 3130) + if st := state(); st.OnePhase != 0 { + t.Fatalf("one phase after under two minutes: %+v", st.OnePhase) + } + // A hole in the readings starts the run again. + seen(1, 160*time.Second, 270*time.Second, 3130) + if st := state(); st.OnePhase != 0 { + t.Fatal("a run with a 45 s hole counted as two minutes") + } + seen(1, 275*time.Second, 285*time.Second, 3100) + if st := state(); st.OnePhase != 1 || st.OnePhaseW != 3100 || st.OnePhaseOrderW != 7590 { + t.Fatalf("after two minutes: phase %d at %.0f W, order %.0f W", st.OnePhase, st.OnePhaseW, st.OnePhaseOrderW) + } + seen(1, 290*time.Second, 290*time.Second, 3130) + if st := state(); st.OnePhaseW != 3130 { + t.Fatalf("rate kept at %.0f W, want the most delivered", st.OnePhaseW) + } + seen(-1, 295*time.Second, 410*time.Second, 9000) + if st := state(); st.OnePhase != 1 { + t.Fatal("cleared before two minutes of three-phase readings") + } + seen(-1, 415*time.Second, 415*time.Second, 9000) + if st := state(); st.OnePhase != 0 || st.OnePhaseW != 0 { + t.Fatal("two minutes of three-phase readings did not clear it") + } + seen(1, 420*time.Second, 540*time.Second, 3130) + m.Observe("garage", false, 0, 0, true) + if st := state(); st.OnePhase != 0 { + t.Fatal("the next car would inherit the one-phase rate") + } +} + +// The plan counts what one phase delivers. The goal's need does too, so the +// box tells the household a goal is at risk from the same rate. +func TestLimitPowerCountsOnePhaseRate(t *testing.T) { + steps := []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} + st := State{PluggedIn: true, MinChargeW: 4140, MaxChargeW: 11040, AllowedStepsW: steps, OnePhase: 1, OnePhaseW: 3130, OnePhaseOrderW: 7590} + minW, maxW, out := LimitPower(st, steps) + if minW != 3130 || maxW != 3130 || !slices.Equal(out, []float64{0, 3130}) { + t.Fatalf("plan power %.0f..%.0f W, steps %v", minW, maxW, out) + } + st.TargetSoC, st.CurrentSoC, st.VehicleCapacityWh, st.TargetTime = .8, .3, 60000, time.Now().Add(8*time.Hour) + if _, powerW, ok := DeadlineNeed(st); !ok || powerW != 3130 { + t.Fatalf("goal need at %.0f W (ok %t), want 3130 W", powerW, ok) + } +} + +// A plan slot at the one-phase rate is 3,130 W. Snapped to the charger's +// three-phase steps that is 4,140 W, 6 A a phase, which a charger stuck on +// one phase turns into 1,380 W. The order that delivered the rate goes out +// instead, until the slot's energy is spent. +func TestOnePhaseRepeatsTheDeliveringOrder(t *testing.T) { + steps := []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350, 11040} + cfgs := []Config{{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040, AllowedStepsW: steps}} + slotStart := time.Now().Add(-time.Second) + directive := &Directive{SlotStart: slotStart, SlotEnd: slotStart.Add(15 * time.Minute), + LoadpointEnergyWh: map[string]float64{"garage": 3130.0 / 4}} + samples := map[string]EVSample{"easee": {PowerW: 3130, Connected: true}} + for _, c := range []struct { + name string + phase int + want float64 + }{{"three phases", 0, 4140}, {"one phase", 1, 7590}} { + sender := &fakeSender{} + ctrl := newTestController(t, cfgs, directive, samples, sender) + ctrl.manager.Observe("garage", true, 3130, 0, true) + if c.phase > 0 { + ctrl.manager.byID["garage"].onePhase = onePhaseState{phase: c.phase, rateW: 3130, orderW: 7590} + } + ctrl.Tick(context.Background(), time.Now()) + if len(sender.calls) != 1 || sender.calls[0].power != c.want { + t.Fatalf("%s: sent %+v, want %.0f W", c.name, sender.calls, c.want) + } + } +} diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 647db565b..ff7470d28 100644 --- a/go/internal/sitesim/sitesim.go +++ b/go/internal/sitesim/sitesim.go @@ -205,6 +205,7 @@ type EVSE struct { Delay time.Duration // from command to current at the car ReadbackLag time.Duration // the charger reports the draw this late LoadBalancerA float64 // > 0: its own balancer keeps each phase under this + StuckPhases int // > 0: draws on this many phases whatever it is told, as an Easee stuck on 1p OfferA float64 // current limit in force OfferPhases int DrawW float64 // the car's draw now @@ -244,6 +245,9 @@ func (e *EVSE) step(now time.Time, car *Car, houseA [3]float64, voltage float64) w := 0.0 if car != nil && car.Plugged && e.OfferA > 0 { e.DrawPhases = min(max(e.OfferPhases, 1), e.Phases, car.Phases) + if e.StuckPhases > 0 { + e.DrawPhases = e.StuckPhases + } a := math.Min(e.OfferA, car.MaxW/voltage/float64(e.DrawPhases)) if e.LoadBalancerA > 0 { for i := range e.DrawPhases { diff --git a/web/control-feedback.js b/web/control-feedback.js index 69c97b1ea..f0eb23862 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 charger draws ${amps(row.charging_phase_a)} on ${row.charging_phase || 'one phase'} only, ${controlPower(actual)} of the ${target(row)} FTW asked for on three phases. FTW plans with what it delivers.`; 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.`; @@ -135,6 +137,7 @@ function nextStep(row) { case 'site_meter_stale': case 'site_phase_currents_stale': return 'Check the grid meter’s connection.'; case 'device_limit': return 'Raise the limit in the charger’s app if you want faster charging.'; case 'offered_current_lower': return 'Check the charger’s load balancing and limits.'; + case 'one_phase': return 'Check the phase setting in the charger’s app.'; case 'observe_only': return 'Turn on control in the device settings if you want FTW to steer it.'; default: return ''; } diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index b504dd07c..0833e60ea 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: 'warning', + sent_w: 7590, actual_w: 3130, charging_phase: 'L1', charging_phase_a: 13.6})); + assert.equal(onePhase.title, 'Charging on one phase'); + assert.equal(onePhase.text, 'The charger draws 14 A on L1 only, 3.1 kW of the 7.6 kW charge FTW asked for on three phases. FTW plans with what it delivers.'); + assert.equal(onePhase.next, 'Check the phase setting in the charger’s app.'); + assert.equal(onePhase.tone, 'warning'); }); test('the receipt walks from sent to confirmed without inventing proof', () => {