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
13 changes: 13 additions & 0 deletions .changeset/ev-confirm-starting-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": patch
---

Wait for a confirmed current battery level before scheduling an EV from an assumed plug-in level. Keep older car readings visible with age and offer a one-action confirmation or slider correction. Reject cached automatic dispatch while confirmation is required; explicit manual and PV-only charging remain available.

Label the household battery projection explicitly so it cannot be mistaken for the EV battery level.

Show the current car target SoC after the charging timeline’s replanning notice, and update it when the charging goal changes. Keep the target visible even when no charging is planned, including when the current battery level already exceeds the goal.

Compare the car SoC target with its current level using ≥ or <; mark older readings and FTW estimates, and avoid comparing an unconfirmed assumed starting level.

Label current car telemetry From Car · Current and a manually confirmed level Confirmed by user. Retain manual provenance within a saved hardware session, and switch to FTW estimate once delivered energy advances the level.
3 changes: 3 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,9 @@ func main() {
socSource = "vehicle:" + pick.Driver
vehicleChargeLimit = pick.ChargeLimit
}
if socSource == "inferred" && !loadpoint.SoCConfirmedForPlan(st) {
continue
}
// Map target time → slot index using the DP's
// actual slot length (hour-of-prices vs. 15-min
// quarters vary by market). Both planners clamp a
Expand Down
3 changes: 3 additions & 0 deletions go/internal/api/api_loadpoint_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) {
for i := range states {
states[i].PlanPending = snapshot.Pending
states[i].PlanOutdated = snapshot.Outdated
if !loadpoint.SoCConfirmedForPlan(states[i]) && !states[i].ManualActive && !states[i].SurplusOnly {
continue
}
if snapshot.Outdated {
continue
}
Expand Down
5 changes: 5 additions & 0 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s
cmdReason = "vehicle_complete"
}
}
// An old plan must not dispatch from an unconfirmed plug-in guess.
// Explicit manual charging and PV-only charging do not use this estimate.
if st, ok := c.manager.State(lpCfg.ID); ok && sched.HasTarget() && !surplusOn && cmdReason != "pv_surplus" && !SoCConfirmedForPlan(st) {
cmdW, cmdReason = 0, "soc_confirmation_required"
}
// Fuse protection: applied LAST (after MPC budget, surplus
// clamp, wake-kick) so all upstream sources see their nominal
// wantW; only the actual ceiling we send to the wallbox is
Expand Down
2 changes: 2 additions & 0 deletions go/internal/loadpoint/controller_bat_soc_unlock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ func scheduledUnlockTick(t *testing.T, cfg Config, budgetWh, batSoC, surplusW fl
}
samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}}
c := newTestController(t, []Config{cfg}, dir, samples, sender)
c.manager.Observe(cfg.ID, true, 0, 0, true)
c.manager.SetCurrentSoC(cfg.ID, .5)
c.manager.SetSchedule(cfg.ID, Schedule{
SoC: 0.8, TimeOfDayMinUTC: 7 * 60, Recurring: true, SurplusUnlockBatSoC: 0.8,
})
Expand Down
2 changes: 2 additions & 0 deletions go/internal/loadpoint/controller_dispatch_safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ func TestStaleSiteMeterStopsScheduledChargeAndRecovers(t *testing.T) {
cfg.DriverName: {Connected: true, RequestActive: true},
}
c := newTestController(t, []Config{cfg}, directive, samples, sender)
c.manager.Observe(cfg.ID, true, 0, 0, true)
c.manager.SetCurrentSoC(cfg.ID, .5)
if !c.manager.SetSchedule(cfg.ID, Schedule{
SoC: 0.8,
TimeOfDayMinUTC: 13 * 60,
Expand Down
12 changes: 11 additions & 1 deletion go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ type State struct {
VehicleStale bool `json:"vehicle_stale,omitempty"`
VehicleSoCAgeS int64 `json:"vehicle_soc_age_s,omitempty"`
SoCSource string `json:"soc_source,omitempty"`
// True only while the current level still equals a user's confirmation,
// before any additional delivered energy advances the estimate.
SoCConfirmedByUser bool `json:"soc_confirmed_by_user,omitempty"`
// VehicleName is the vehicle profile the session identified (via the
// charging transaction's idTag/idToken), empty when none matched.
VehicleName string `json:"vehicle_name,omitempty"`
Expand Down Expand Up @@ -428,7 +431,8 @@ type loadpointRuntime struct {

// socConfirmed is true only after a level from the user or a matched car.
// A configured/default plug-in level remains a planning assumption.
socConfirmed bool
socConfirmed bool
socUserConfirmedAtWh *float64

// surplusWithheld is set by the controller each tick: true when WE
// are intentionally withholding power from this loadpoint (a
Expand Down Expand Up @@ -598,6 +602,7 @@ func (m *Manager) Load(cfgs []Config) {
lp.chargingDeclined = existing.chargingDeclined
lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName
if existing.DriverName == c.DriverName {
lp.socUserConfirmedAtWh = existing.socUserConfirmedAtWh
lp.sessionDeviceID = existing.sessionDeviceID
lp.sessionID = existing.sessionID
lp.socRetention = existing.socRetention
Expand Down Expand Up @@ -757,6 +762,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64
}
lp.sessionPluginSoC = anchor
lp.socConfirmed = false
lp.socUserConfirmedAtWh = nil
lp.completionNotified = false
lp.deliveredHistory = nil
lp.notRequestingSince = time.Time{}
Expand Down Expand Up @@ -1080,6 +1086,8 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool {
lp.chargingDeclined = false
lp.notRequestingSince = time.Time{}
reanchorSoCLocked(lp, socPct)
confirmedWh := lp.deliveredWhSession
lp.socUserConfirmedAtWh = &confirmedWh
return true
}

Expand Down Expand Up @@ -1213,6 +1221,7 @@ func reanchorSoCLocked(lp *loadpointRuntime, soc float64) {
func reanchorSoCAtLocked(lp *loadpointRuntime, soc, deliveredWh float64) {
soc = units.ClampFraction(soc)
lp.socConfirmed = true
lp.socUserConfirmedAtWh = nil
// Re-anchor: new_anchor + delivered/capacity == soc.
delivered := 0.0
if lp.VehicleCapacityWh > 0 {
Expand All @@ -1239,6 +1248,7 @@ func (lp *loadpointRuntime) snapshot() State {
DriverName: lp.DriverName,
PluggedIn: lp.pluggedIn,
CurrentSoC: lp.currentSoC,
SoCConfirmedByUser: lp.pluggedIn && lp.socConfirmed && lp.socUserConfirmedAtWh != nil && lp.deliveredWhSession == *lp.socUserConfirmedAtWh,
CurrentPowerW: lp.currentPowerW,
DeliveredWhSession: lp.deliveredWhSession,
TargetSoC: lp.targetSoC,
Expand Down
11 changes: 11 additions & 0 deletions go/internal/loadpoint/planning_soc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package loadpoint

import "math"

// SoCConfirmedForPlan excludes default plug-in guesses and completion estimates.
// A user correction or a matched car anchor supplies the starting level.
func SoCConfirmedForPlan(st State) bool {
return !math.IsNaN(st.CurrentSoC) && !math.IsInf(st.CurrentSoC, 0) &&
st.CurrentSoC >= 0 && st.CurrentSoC <= 1 &&
st.SoCSource != "assumed" && st.SoCSource != "completed"
}
57 changes: 57 additions & 0 deletions go/internal/loadpoint/planning_soc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package loadpoint

import (
"context"
"math"
"testing"
"time"
)

func TestPlanningRequiresConfirmedStartingLevel(t *testing.T) {
for _, source := range []string{"assumed", "completed"} {
if SoCConfirmedForPlan(State{CurrentSoC: .2, SoCSource: source}) {
t.Fatal(source)
}
}
for _, soc := range []float64{math.NaN(), math.Inf(1), -.01, 1.01} {
if SoCConfirmedForPlan(State{CurrentSoC: soc}) {
t.Fatal(soc)
}
}
for _, source := range []string{"", "inferred", "vehicle"} {
if !SoCConfirmedForPlan(State{CurrentSoC: .86, SoCSource: source}) {
t.Fatal(source)
}
}
}
func TestUnconfirmedSoCRejectsCachedScheduleUntilCorrection(t *testing.T) {
now := time.Now()
cfg := Config{ID: "easee", DriverName: "easee-cloud", VehicleCapacityWh: 86500, MinChargeW: 4140, MaxChargeW: 11000}
sender := &fakeSender{}
dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"easee": 11000}}
c := newTestController(t, []Config{cfg}, dir, map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}}, sender)
c.manager.SetSchedule(cfg.ID, Schedule{SoC: .9, TimeOfDayMinUTC: 420, Recurring: true})
c.Tick(context.Background(), now)
last, ok := lastSetCurrent(sender.calls)
if !ok || last.power != 0 {
t.Fatalf("unconfirmed schedule dispatched: %+v", last)
}
st, _ := c.manager.State(cfg.ID)
if st.CommandedReason != "soc_confirmation_required" {
t.Fatal(st.CommandedReason)
}
if !c.manager.SetCurrentSoC(cfg.ID, .86) {
t.Fatal("correction rejected")
}
c.Tick(context.Background(), now.Add(time.Second))
last, _ = lastSetCurrent(sender.calls)
if last.power <= 0 {
t.Fatalf("confirmed schedule did not resume: %+v", last)
}
c.manager.Observe(cfg.ID, false, 0, 0, true)
c.Tick(context.Background(), now.Add(2*time.Second))
last, _ = lastSetCurrent(sender.calls)
if last.power != 0 {
t.Fatal("new unconfirmed session reused confirmed plan")
}
}
13 changes: 12 additions & 1 deletion go/internal/loadpoint/session_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type savedSession struct {
SessionID string `json:"session_id"`
AnchorSoC float64 `json:"anchor_soc"`
ConfirmedAtWh float64 `json:"confirmed_at_wh"`
UserConfirmedAtWh *float64 `json:"user_confirmed_at_wh,omitempty"`
CapacityWh float64 `json:"capacity_wh"`
EstimatedWh *float64 `json:"estimated_wh,omitempty"`
EstimatedAt time.Time `json:"estimated_at,omitempty"`
Expand Down Expand Up @@ -132,6 +133,10 @@ func (m *Manager) ObserveSample(id string, sample EVSample) {
// a level. Keep that correction while joining the measured time line.
baseline := lp.energy.counterWh - lp.energy.integralAt(lp.energy.counterAt)
lp.sessionPluginSoC -= baseline * DefaultChargeEfficiency / lp.VehicleCapacityWh
if lp.socUserConfirmedAtWh != nil {
adjusted := *lp.socUserConfirmedAtWh + baseline
lp.socUserConfirmedAtWh = &adjusted
}
}
if changed {
lp.chargingPeriodSince = time.Time{}
Expand Down Expand Up @@ -194,6 +199,10 @@ func (m *Manager) ObserveSample(id string, sample EVSample) {
lp.sessionPluginSoC = restore.AnchorSoC
lp.currentSoC = estimateSoC(restore.AnchorSoC, deliveredWh, restore.CapacityWh)
lp.socConfirmed = true
lp.socUserConfirmedAtWh = nil
if restore.UserConfirmedAtWh != nil && finite(*restore.UserConfirmedAtWh) && *restore.UserConfirmedAtWh >= 0 && *restore.UserConfirmedAtWh <= deliveredWh {
lp.socUserConfirmedAtWh = restore.UserConfirmedAtWh
}
lp.completionNotified = restore.CompletionNotified
lp.socRetention = "session"
} else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil {
Expand Down Expand Up @@ -259,7 +268,8 @@ func (m *Manager) persistSession(id string) {
}
record := savedSession{Version: 2, DeviceID: lp.sessionDeviceID, SessionID: lp.sessionID,
AnchorSoC: lp.sessionPluginSoC, ConfirmedAtWh: lp.deliveredWhSession,
CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified}
UserConfirmedAtWh: lp.socUserConfirmedAtWh,
CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified}
if lp.energy != nil && lp.energy.counterKnown {
record.ConfirmedAtWh = lp.energy.counterWh
if lp.deliveredWhSession > lp.energy.counterWh {
Expand Down Expand Up @@ -330,6 +340,7 @@ func (m *Manager) observeConnectionProof(id string, generation uint64, unknown b
m.nextSessionGeneration++
lp.sessionGeneration = m.nextSessionGeneration
lp.socConfirmed = false
lp.socUserConfirmedAtWh = nil
lp.socRetention = "unavailable"
if lp.vehicleName != "" || lp.capacityFromCar {
lp.VehicleCapacityWh = lp.baseCapacityWh
Expand Down
30 changes: 30 additions & 0 deletions go/internal/loadpoint/session_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,33 @@ func TestLegacySessionAnchorMigratesWithoutChangingConfirmedSoC(t *testing.T) {
t.Fatalf("post-confirmation loss missing: %v", s.CurrentSoC)
}
}

func TestUserSoCProvenanceSurvivesSameSessionAndEndsAfterDelivery(t *testing.T) {
store := &sessionMemory{data: map[string]string{}}
m := sessionManager(store, "garage", "charger")
m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "connection-1")
m.SetCurrentSoC("garage", .86)
if st, _ := m.State("garage"); !st.SoCConfirmedByUser {
t.Fatal("manual level lost its source")
}
m = sessionManager(store, "garage", "charger")
m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "connection-1")
if st, _ := m.State("garage"); !st.SoCConfirmedByUser {
t.Fatal("same-session restore lost user confirmation")
}
m.ObserveSession("garage", true, 0, 9600, true, "easee:ABC", "connection-1")
if st, _ := m.State("garage"); st.SoCConfirmedByUser {
t.Fatal("delivered-energy estimate still claims user confirmation")
}
m.SetCurrentSoC("garage", .87)
m.AnchorVehicleSoC("garage", .88)
if st, _ := m.State("garage"); st.SoCConfirmedByUser {
t.Fatal("vehicle anchor claims user confirmation")
}
m.SetCurrentSoC("garage", .88)
m.ObserveSession("garage", false, 0, 9600, true, "easee:ABC", "connection-1")
m.ObserveSession("garage", true, 0, 0, true, "easee:ABC", "connection-2")
if st, _ := m.State("garage"); st.SoCConfirmedByUser {
t.Fatal("new session reused user confirmation")
}
}
2 changes: 2 additions & 0 deletions go/internal/loadpoint/vehicle_completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func TestVehicleLimitCompletionKeepsChargingAndSafetyWins(t *testing.T) {
sender := &fakeSender{}
dir := &Directive{SlotStart: now.Add(-time.Second), SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}}
c := newTestController(t, []Config{cfg}, dir, samples, sender)
c.manager.Observe(cfg.ID, true, 0, 0, true)
c.manager.SetCurrentSoC(cfg.ID, 1)
c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 5 * 60, Recurring: true})
c.manager.RollSchedules(now)
c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3})
Expand Down
3 changes: 3 additions & 0 deletions go/internal/mpc/service_requested_replan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ func TestFailedGoalReplanStopsOldEVAllocationButKeepsManualControl(t *testing.T)
}
return sent[len(sent)-1]
}
// Confirm the starting level for this session before scheduling.
ctrl.Tick(context.Background(), now)
mgr.SetCurrentSoC("garage", .5)
if got := tickPower(); got <= 0 {
t.Fatalf("initial plan did not charge: %v", got)
}
Expand Down
25 changes: 22 additions & 3 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3847,6 +3847,19 @@
note.style.marginTop = "0.3rem";
note.style.minHeight = "1em";
socWrap.appendChild(note);
var confirmCar = document.createElement("button");
confirmCar.type = "button";
confirmCar.hidden = true;
socWrap.appendChild(confirmCar);
confirmCar.addEventListener("click", function () {
var reported = lastLp.vehicle_soc == null ? 0 : lastLp.vehicle_soc;
if (!lastLp.vehicle_driver || !lastLp.vehicle_stale || !isFinite(reported) || reported < 0 || reported > 1) return;
socRevision++;
socFailed = false;
socPending = Math.round(reported * 100);
note.textContent = "Confirming charge level: " + socPending + " %…";
sendSoc();
});

box.appendChild(levels);
box.appendChild(levelsNote);
Expand Down Expand Up @@ -3879,10 +3892,12 @@
: lpNow.soc_retention === "error"
? " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting."
: " This level must be entered again after a box restart.";
if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car." + retention;
if (src === "assumed") return "Battery level needs confirmation. Scheduled charging waits until you confirm the level. Drag to match the car." + retention;
if (src === "vehicle") return "The car reports its current level automatically.";
if (src === "completed") return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car.";
return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention;
return (lpNow.soc_confirmed_by_user
? "Confirmed by user. Drag to update the level and the plan follows."
: "Estimated from energy delivered. Drag to the real value and the plan follows.") + retention;
}

var socPending = null;
Expand Down Expand Up @@ -3958,7 +3973,11 @@
levels.hidden = !lpNow.plugged_in;
levelsNote.hidden = !lpNow.plugged_in;
var plugged = !!(lpNow && lpNow.plugged_in);
socWrap.hidden = !plugged || !!(info && info.fromCar);
var oldCar = !!(info && info.fromCar && lpNow.vehicle_stale);
socWrap.hidden = !plugged || !!(info && info.fromCar && !oldCar);
confirmCar.hidden = !oldCar;
confirmCar.disabled = !!lpNow.read_unavailable || socSaving;
confirmCar.textContent = oldCar ? "Confirm " + Math.round((lpNow.vehicle_soc || 0) * 100) + " % matches the car now" : "";
slider.disabled = !!lpNow.read_unavailable;
if (!plugged) return;
var cur = (lpNow.current_soc != null) ? Math.max(0, Math.min(100, Math.round(lpNow.current_soc * 100))) : null;
Expand Down
Loading
Loading