Skip to content
Merged
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-car.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion go/cmd/ftw/ev_planning_limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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
35 changes: 31 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 @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions go/internal/api/api_ev_one_phase_car_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
71 changes: 71 additions & 0 deletions go/internal/api/api_one_phase_order_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
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, 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
}
32 changes: 21 additions & 11 deletions go/internal/control/sitesim_field_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}
}
Expand Down
Loading
Loading