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/vehicle-stopped-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Explain a charger-reported vehicle stop that lasts 90 seconds while FTW offers power, without reporting a no-power fault or assuming full SoC. A brief stop during the session handshake no longer hides a failed start or blocks the resume and wake after FTW's own pause. FTW's own 0 W stop for a car that stopped asking counts as following again.
2 changes: 1 addition & 1 deletion go/cmd/ftw/ev_observation.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa
active = *d.RequestActive
}
sample := loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.RawW,
PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID,
PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestKnown: d.RequestActive != nil, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID,
SessionWhUnavailable: d.SessionWh == nil}
_, powerKnown := telemetry.ControlPowerObservation(sample.PowerW, r.Data, r.UpdatedAt)
sample.PowerUnavailable = !powerKnown
Expand Down
18 changes: 18 additions & 0 deletions go/cmd/ftw/ev_observation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,21 @@ func TestEVObservationKeepsUnknownPowerSeparateFromZero(t *testing.T) {
}
}
}

func TestEVObservationDistinguishesExplicitVehicleStopFromMissingRequest(t *testing.T) {
now := time.Now()
h := &telemetry.DriverHealth{Status: telemetry.StatusOk}
for _, tc := range []struct {
body string
known, active bool
}{
{`{"connected":true,"request_active":false}`, true, false},
{`{"connected":true,"request_active":true}`, true, true},
{`{"connected":true}`, false, true},
} {
s, ok := currentEVSample(&telemetry.DerReading{UpdatedAt: now, Data: json.RawMessage(tc.body)}, h, time.Minute, now, false, "box")
if !ok || s.RequestKnown != tc.known || s.RequestActive != tc.active {
t.Fatalf("%s: %+v", tc.body, s)
}
}
}
47 changes: 34 additions & 13 deletions go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,20 @@ import (
// are null, never invented zeros. Reasons describe only what Core or the
// device reported, not a guessed physical cause.
type ControlFeedback struct {
Driver string `json:"driver"`
Kind string `json:"kind"`
Mode string `json:"mode"`
Status string `json:"status"`
Reason string `json:"reason"`
Severity string `json:"severity"`
Evidence string `json:"evidence"`
ReadingsFresh bool `json:"readings_fresh"`
ConfirmedAtMs int64 `json:"confirmed_at_ms,omitempty"`
SiteConfirmation string `json:"site_confirmation"`
SiteMeter string `json:"site_meter,omitempty"`
SiteEvidence *ControlComparison `json:"site_evidence,omitempty"`
ToleranceW *float64 `json:"tolerance_w"`
VehicleRequestActive *bool `json:"vehicle_request_active,omitempty"`
Driver string `json:"driver"`
Kind string `json:"kind"`
Mode string `json:"mode"`
Status string `json:"status"`
Reason string `json:"reason"`
Severity string `json:"severity"`
Evidence string `json:"evidence"`
ReadingsFresh bool `json:"readings_fresh"`
ConfirmedAtMs int64 `json:"confirmed_at_ms,omitempty"`
SiteConfirmation string `json:"site_confirmation"`
SiteMeter string `json:"site_meter,omitempty"`
SiteEvidence *ControlComparison `json:"site_evidence,omitempty"`
ToleranceW *float64 `json:"tolerance_w"`
// Internal steps of the verdict; the API exposes their result above.
State string `json:"-"`
VerificationTier *int `json:"-"`
Expand Down Expand Up @@ -74,9 +75,13 @@ type ControlFeedback struct {
SinceMs int64 `json:"since_ms,omitempty"`
CommandAtMs int64 `json:"command_at_ms,omitempty"`
ObservedAtMs int64 `json:"observed_at_ms,omitempty"`
// VehicleDeclined is Core's debounced record that the car refused its
// offer. The reason carries the result.
VehicleDeclined bool `json:"-"`
}

type feedbackReading struct {
RequestActive *bool `json:"request_active"`
SetpointW *float64 `json:"setpoint_w"`
MaxA *float64 `json:"max_a"`
PilotA *float64 `json:"pilot_a"`
Expand Down Expand Up @@ -270,6 +275,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback {
fresh = false
f.ActualW = nil
}
f.VehicleDeclined = lp.VehicleDeclinesOffer(now)
}
// 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 @@ -310,6 +316,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback {
f.ConfirmedAtMs = comparison.AfterAt.UnixMilli()
}
}
f.VehicleRequestActive = d.RequestActive
classifyControlFeedback(&f, cmd, commanded, fresh, coreReason, d.Connected, now)
if h := s.deps.Tel.DriverHealth(rd.Driver); h != nil && h.DeviceFault {
f.State, f.Reason, f.Severity = "blocked", "device_fault", "warning"
Expand Down Expand Up @@ -467,6 +474,7 @@ const (
func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, commanded, fresh bool, coreReason string, connected *bool, now time.Time) {
set := func(state, reason, severity string) { f.State, f.Reason, f.Severity = state, reason, severity }
if !fresh {
f.VehicleRequestActive = nil
f.ActualW, f.ReadbackW, f.OfferedA, f.DeviceLimitA, f.LimitA, f.LimitedBy, f.Make = nil, nil, nil, nil, nil, "", ""
f.PilotA, f.DynamicLimitA = nil, nil
f.PilotObservedAt, f.PilotConfirmedAtMs = "", 0
Expand All @@ -493,6 +501,19 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("waiting", "not_connected", "info")
return
}
// The car declined Core's offer: the driver says so now, and Core has seen
// it for SessionCompletionTimeout. That explains zero draw, not a full
// battery or a met goal; vehicle_not_requesting is Core's own 0 W stop.
// Failed commands, stale data and safety pauses keep their own verdicts.
if f.Kind == "ev" && commanded && cmd.Result == "accepted" && f.VehicleDeclined &&
connected != nil && *connected && f.VehicleRequestActive != nil && !*f.VehicleRequestActive &&
(f.OfferedA == nil || f.RequestedA == nil || *f.OfferedA+0.5 >= *f.RequestedA) &&
coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" &&
coreReason != "fuse_limit" && coreReason != "import_limit" &&
f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 {
set("waiting", "vehicle_declined", "info")
return
}
// A positive clamp explains the lower offer, not prolonged zero delivery.
// Keep known safety pauses and unconfirmed/stale commands authoritative.
evMismatchObserved := !cmd.PowerMismatchSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= evMismatchWait
Expand Down
158 changes: 158 additions & 0 deletions go/internal/api/api_vehicle_stopped_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package api

import (
"fmt"
"testing"
"testing/synctest"
"time"

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

// Keep this fixture independent of the pilot/load-balancer PR's evTrace changes.
// stopped is the driver's request_active=false (Easee reason 50 or op mode 4).
type vehicleStopTrace struct {
*evTrace
stopped bool
maxA float64
}

func (e *vehicleStopTrace) tick(w float64, reason string) {
c := e.tel.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%g}`, w)), time.Now())
e.tel.CompleteCommand(c, "accepted")
e.mgr.SetCommanded("garage", w, reason)
extra := `,"request_active":true`
if e.stopped {
extra = `,"request_active":false,"op_mode":4`
}
if e.maxA > 0 {
extra += fmt.Sprintf(`,"max_a":%g`, e.maxA)
}
e.tel.Update("easee", telemetry.DerEV, e.drawW, nil, []byte(fmt.Sprintf(`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, e.changedAt.Format(time.RFC3339Nano), extra)))
e.mgr.Observe("garage", true, e.drawW, 0, !e.stopped)
time.Sleep(5 * time.Second)
}

// Field report #1976: an Easee charger said Completed at the car's own 80 %
// limit while Core still asked for 10.35 kW. A stop that lasts is explained
// without a fault or a full battery; until it lasts, Core keeps waiting.
func TestVehicleStopExplainsZeroDrawButNotAFullBattery(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := &vehicleStopTrace{evTrace: newEVTrace(8200), maxA: 12}
for range 30 {
e.tick(8280, "plan")
}
e.drawW, e.changedAt, e.stopped, e.maxA = 0, time.Now(), true, 15
stop := time.Now()
for range 12 * 60 / 5 {
e.tick(10350, "plan")
f, since := e.verdict(t), time.Since(stop)
want := "vehicle_declined"
if since < loadpoint.SessionCompletionTimeout {
want = "waiting_response"
}
if f.Reason != want || f.Status == "not_following" || f.Severity != "info" {
t.Fatalf("at %v want %s: %+v", since, want, f)
}
states := e.srv.ControlStates(time.Now())
if len(states) != 1 || states[0].Status == "not_following" {
t.Fatalf("notification verdict: %+v", states)
}
}
// A new active vehicle request at zero power remains a genuine failed start.
e.stopped = false
for range 5 * 60 / 5 {
e.tick(10350, "plan")
}
if f := e.verdict(t); f.Status != "not_following" || f.Reason != "no_power_response" {
t.Fatalf("failed start hidden: %+v", f)
}
})
}

// Easee can report reason 50 for a moment during the session handshake. A
// blip during a failed start must not explain the missing power.
func TestVehicleStopBlipKeepsFailedStartVerdict(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := &vehicleStopTrace{evTrace: newEVTrace(0), maxA: 15}
start := time.Now()
for range 60 / 5 {
e.tick(10350, "plan")
}
e.stopped = true
for range 3 {
e.tick(10350, "plan")
if f := e.verdict(t); f.Reason != "waiting_response" {
t.Fatalf("handshake blip at %v: %+v", time.Since(start), f)
}
}
e.stopped = false
for range 90 / 5 {
e.tick(10350, "plan")
}
if f := e.verdict(t); f.Reason != "no_power_response" || f.Status != "not_following" {
t.Fatalf("failed start at %v: %+v", time.Since(start), f)
}
e.stopped = true
for range 60 / 5 {
e.tick(10350, "plan")
if f := e.verdict(t); f.Reason != "no_power_response" || f.Status != "not_following" {
t.Fatalf("blip hid a failed start at %v: %+v", time.Since(start), f)
}
}
})
}

// Core's own 0 W stop for a car that stopped asking is Core in control.
func TestCoreStopForANotRequestingCarIsFollowing(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
e := &vehicleStopTrace{evTrace: newEVTrace(0), stopped: true}
for range 60 / 5 {
e.tick(0, "vehicle_not_requesting")
}
if f := e.verdict(t); f.Reason != "vehicle_not_requesting" || f.Status != "following" || f.Evidence != "measured" {
t.Fatalf("Core's own stop: %+v", f)
}
})
}

func TestVehicleStopNeverOverridesStaleFailedOrContradictoryEvidence(t *testing.T) {
now := time.Now()
no := false
yes := true
for _, tc := range []struct {
name string
request *bool
declined bool
fresh bool
result string
actual float64
reason string
}{
{"lasting stop", &no, true, true, "accepted", 0, "vehicle_declined"},
{"brief stop", &no, false, true, "accepted", 0, "no_power_response"},
{"missing request", nil, true, true, "accepted", 0, "no_power_response"},
{"active request", &yes, true, true, "accepted", 0, "no_power_response"},
{"stale completed", &no, true, false, "accepted", 0, "telemetry_stale"},
{"command failed", &no, true, true, "failed", 0, "command_failed"},
{"unconfirmed command", &no, true, true, "unconfirmed", 0, "command_unconfirmed"},
{"unexpected draw", &no, true, true, "accepted", 12000, "power_above_target"},
{"lower held offer", &no, true, true, "accepted", 0, "offered_current_lower"},
} {
t.Run(tc.name, func(t *testing.T) {
f := ControlFeedback{Kind: "ev", VehicleRequestActive: tc.request, VehicleDeclined: tc.declined, SentW: watts(10350), RequestedW: watts(10350), RequestedA: watts(15), OfferedA: watts(15), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli()}
if tc.name == "lower held offer" {
f.OfferedA = watts(0)
}
cmd := telemetry.CommandEvidence{Result: tc.result, Since: now.Add(-10 * time.Minute), PowerMismatchSince: now.Add(-5 * time.Minute), LastObservation: now}
classifyControlFeedback(&f, cmd, true, tc.fresh, "plan", &yes, now)
if f.Reason != tc.reason {
t.Fatalf("reason %s want %s", f.Reason, tc.reason)
}
if !tc.fresh && f.VehicleRequestActive != nil {
t.Fatal("stale request exposed")
}
})
}
}
5 changes: 3 additions & 2 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ type EVSample struct {
PowerMaxAge time.Duration
EnergyAt time.Time
Connected bool
RequestKnown bool
RequestActive bool
DeviceID string
SessionID string
Expand Down Expand Up @@ -1748,7 +1749,7 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg
sample, now, live = c.currentChargeSample(now, lpCfg, sample)
// Vehicle APIs can lag the charger by minutes. Do not interrupt measured
// delivery, or turn missing charger power into evidence of a sleeping car.
if !live || !chargerIdleForWake(sample, now) {
if !live || !chargerIdleForWake(sample, now) || c.vehicleDeclinesOffer(lpID, sample, now) {
return
}
pw, _ := cmd["power_w"].(float64)
Expand Down Expand Up @@ -1860,7 +1861,7 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg
// gets this for free.
//
latest, checkedAt, live := c.currentChargeSample(now, lpCfg, sample)
if !live || !chargerIdleForWake(latest, checkedAt) {
if !live || !chargerIdleForWake(latest, checkedAt) || c.vehicleDeclinesOffer(lpID, latest, checkedAt) {
return
}
c.cycleWallbox(lpID, lpCfg.DriverName)
Expand Down
21 changes: 21 additions & 0 deletions go/internal/loadpoint/controller_resume.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ func chargerIdleForWake(sample EVSample, now time.Time) bool {
!sample.PowerAt.After(now) && (sample.PowerAt.IsZero() || now.Sub(sample.PowerAt) <= sample.PowerWindow())
}

// vehicleDeclinesOffer needs fresh charger data that says the car stopped
// asking, and a stop that has lasted SessionCompletionTimeout while Core
// offered current. A handshake blip, Core's own pause, or stale or unknown
// evidence is not enough. It says nothing about the battery level.
func (c *Controller) vehicleDeclinesOffer(id string, sample EVSample, now time.Time) bool {
if c == nil || c.manager == nil || !sample.RequestKnown || sample.RequestActive || !sample.Connected || sample.ConnectionUnknown ||
sample.PowerUnavailable || !finite(sample.PowerW) || sample.PowerW < 0 || sample.PowerW >= DeliveringW ||
sample.PowerAt.IsZero() || sample.PowerAt.After(now) || now.Sub(sample.PowerAt) > min(sample.PowerWindow(), resumeObservationGap) {
return false
}
st, ok := c.manager.State(id)
return ok && st.VehicleDeclinesOffer(now)
}

// recoverOfferedCharge resumes our zero offer, or a known plug session that
// still asks for charge after two minutes of accepted positive offers without
// delivery. The current limit must succeed before this optional resume. Only
Expand All @@ -66,6 +80,13 @@ func (c *Controller) recoverOfferedCharge(ctx context.Context, cfg Config, sampl
c.resumeOffers[cfg.ID] = &proof
return
}
// A lasting vehicle-side stop is not a failed start. Do not infer a full
// battery or mark its goal complete; a later active request re-enables
// recovery. A brief stop still gets the resume after Core's own pause.
if c.vehicleDeclinesOffer(cfg.ID, sample, now) {
delete(c.resumeOffers, cfg.ID)
return
}
// Ten seconds near the supported minimum prove recovery. A normal
// charge with no recovery in progress needs no retry record at all.
minimum := cfg.MinChargeW
Expand Down
Loading
Loading