diff --git a/.changeset/ev-one-phase-car.md b/.changeset/ev-one-phase-car.md new file mode 100644 index 00000000..eba44d36 --- /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 da187e39..570daee7 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 18cc4c43..b79ff7e6 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 f63a24a1..1ed31dd9 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" @@ -484,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_ev_one_phase_car_test.go b/go/internal/api/api_ev_one_phase_car_test.go new file mode 100644 index 00000000..856c059b --- /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/api/api_one_phase_order_test.go b/go/internal/api/api_one_phase_order_test.go new file mode 100644 index 00000000..9e153204 --- /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/fuse_heat.go b/go/internal/control/fuse_heat.go index 305d9f5a..e80c5e70 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_field_test.go b/go/internal/control/sitesim_field_test.go index bf2ef5fa..c4294011 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) } } } diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 21b9d557..61e0fba6 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/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 006f47c3..f3a4a200 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 diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index bd61fe67..89bdca6b 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 0c84bb12..7bcb105f 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 00000000..ca2c1d81 --- /dev/null +++ b/go/internal/loadpoint/one_phase_car.go @@ -0,0 +1,279 @@ +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 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 + onePhaseHold = 30 * time.Second // a verdict holds this long on the meter + // 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 +// 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 + 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 +} + +// 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 + // 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 + 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 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) + 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++ + } + } + sum := moved[0] + moved[1] + moved[2] + switch { + case !finite(drawA): + return 0 + case rose == 2 && drawA >= 0.6*sum && drawA <= 1.5*sum: + return -1 + 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)) { + 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, 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 { + 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 + } + 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 +// 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 + 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, s.steady = nil, false, 0 + 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: + s.orderA, s.rising = orderA, false // A cut is the base of the next rise. + case !seen: + s.orderA = orderA // The order was out before the first reading. + case !s.rising: + s.rising, s.before, s.beforeDrawA = true, previous, previousDrawA + } + 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, + 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-t.baseDrawA); 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 +} + +// 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 { + 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 00000000..d1c46897 --- /dev/null +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -0,0 +1,244 @@ +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 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 < 160*time.Second { + return threePhase(7)(d) + } + return threePhase(16)(d) + } + 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) + } + 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) + } +} + +// 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) + } + } +} diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 632d0503..0ed28443 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 52820535..5761d98a 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 d45adce7..84125808 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 69c97b1e..e8164403 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 b504dd07..9250313c 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', () => {