Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-one-phase.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 24 additions & 4 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"encoding/json"
"fmt"
"math"
"sort"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions go/internal/api/api_ev_one_phase_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 19 additions & 0 deletions go/internal/control/fuse_heat.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"math"
"time"

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

Expand Down Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Comment on lines +2501 to +2502

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Separate the delivered-rate ceiling from the hardware order ceiling

When a detected 3,130 W session is replanned, LimitPower supplies 3,130 W as the MPC maximum, and coreReservePlan emits that value in LoadpointMaxPowerW. The preceding duty-plan branch therefore lowers maxW to 3,130 W, so this assignment clips the saved 7,590 W order back to 3,130 W. The final SnapChargeW(3130, 4140, 3130, steps) returns zero because the ceiling is below the charger's minimum. Consequently, the reserve/duty-plan path stops charging after detecting precisely the one-phase case this change addresses. Keep the modeled delivered-power ceiling separate from the legal hardware-order ceiling, and cover a directive containing LoadpointMaxPowerW in the regression test.

Useful? React with 👍 / 👎.

}
// 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.
Expand Down
15 changes: 14 additions & 1 deletion go/internal/loadpoint/deadline_need.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package loadpoint

import (
"math"
"slices"
)

// DeadlineNeed is the remaining AC energy and legal on-power of a saved goal,
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading