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
4 changes: 4 additions & 0 deletions .changeset/vehicle-limit-goal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"ftw": minor
---
Support charging to the car's own limit as a distinct saved goal. Keep unfinished charging active after an estimated target or a missed deadline, retain the same session's deadline across restart, and preserve explicit percentage goals and safety limits. Clients must check Core support before offering this goal.
24 changes: 24 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ A separate module requires a concrete benefit and:
- no authority to bypass core's validation or safety limits;
- a useful fallback or a cleanly unavailable state.

### Vehicle charge-limit goals

A percentage goal and a goal to reach the car's own limit are distinct.
State schema 7 blocks rollback to a Core that would ignore the saved goal mode.
`GET /api/loadpoints` advertises `vehicle_limit_goal_supported`; clients must
require that flag before saving `schedule.finish_at_vehicle_limit`. Existing
percentage goals keep their meaning. In vehicle-limit mode, the planner uses
a fresh vehicle limit where available. Without one, 100% is a planning bound,
not a claimed vehicle setting. Final charging continues through Core's safety
clamps until the car stops accepting current; an estimate cannot prove it is
finished. A manual Stop still wins.

The current connection's deadline uses verified charger and session identity.
It stays due after the deadline and survives restart when `goal_retention` is
`session`. `unavailable` means that identity is missing; `error` means the
session checkpoint failed. Neither means the saved schedule disappeared.
Core assigns each saved vehicle-limit goal an `intent_id` and a one-shot
`first_deadline_ms`; clients send user choices, not those bookkeeping fields.
A fresh vehicle Complete can finish a one-shot goal across restart and later
plug sessions. Completion requires one vehicle source, one connected loadpoint,
a reading after the observed connection and no measured charging. An ambiguous
match cannot finish the goal. A charger declining current is reported as a refusal, never
as an invented battery level or proof that the target was reached.

## Product requirements across these boundaries

Discovery, first-day models and controlled commissioning should establish
Expand Down
42 changes: 17 additions & 25 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1627,7 +1627,7 @@ func main() {
// no schedule, EV is left to the loadpoint controller's
// reactive surplus-only behaviour.
if effectiveTarget <= 0 || effectiveTargetTime.IsZero() ||
!effectiveTargetTime.After(time.Now()) {
(!effectiveTargetTime.After(time.Now()) && !st.FinishAtVehicleLimit) {
continue
}
// Pull capacity off the configured loadpoint.
Expand Down Expand Up @@ -1674,31 +1674,15 @@ func main() {
targetSlot = int(delta / (time.Duration(slotLenMin) * time.Minute))
}
}
// Operational ceiling: the lower of the user's target
// and the vehicle-configured charge limit. The car
// won't accept current beyond charge_limit_pct anyway,
// so planning past it is wasted DP grid space. When
// the limit is unknown, fall back to the deadline
// target itself; never plan beyond what was requested.
maxSoC := effectiveTarget
if vehicleChargeLimit > 0 && vehicleChargeLimit < maxSoC {
maxSoC = vehicleChargeLimit
goal := st
goal.TargetSoC = effectiveTarget
if boostActive {
goal.FinishAtVehicleLimit = false
}
// Effective deadline target: when the operator asked
// for 100% but the vehicle (Tesla via TeslaBLEProxy
// etc.) is hard-capped at, say, 60%, the DP must plan
// against the cap — otherwise the deadline-shortfall
// penalty stays elevated forever (the SoC grid maxes
// at the cap, can never reach the operator target),
// and MPC keeps committing grid charging chasing an
// unreachable goal. Cap target_pct to whatever the
// car will physically accept.
targetSoC := effectiveTarget
if vehicleChargeLimit > 0 && vehicleChargeLimit < targetSoC {
targetSoC = vehicleChargeLimit
slog.Info("mpc: target capped to vehicle charge limit",
"lp", st.ID, "operator_target", effectiveTarget,
"vehicle_limit", vehicleChargeLimit)
targetSoC := loadpoint.PlanningTarget(goal, vehicleChargeLimit)
maxSoC := targetSoC
if st.FinishAtVehicleLimit && !effectiveTargetTime.After(time.Now()) {
targetSlot = 0
}
// Guard against degenerate grids: if current SoC > maxSoC
// (already over target), grow the ceiling to current so
Expand Down Expand Up @@ -2150,6 +2134,14 @@ func main() {
return pick.Driver, pick.ChargingState, true
})

lpController.SetVehicleChargeState(func(lpID string) (loadpoint.VehicleChargeState, bool) {
pick := telemetry.PickVehicleForCompletion(tel, time.Now())
if pick.Driver == "" || pick.Stale || !lpMgr.VehicleObservationApplies(lpID, pick.UpdatedAt) {
return loadpoint.VehicleChargeState{}, false
}
return loadpoint.VehicleChargeState{SoC: pick.SoC, Limit: pick.ChargeLimit, State: pick.ChargingState}, true
})

// Wire the EV-available surplus computation for the
// surplus_only clamp. We want the W of PV that exceeds house
// load, regardless of how the home battery is currently
Expand Down
7 changes: 4 additions & 3 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3429,7 +3429,7 @@ func (s *Server) handleEVChargers(w http.ResponseWriter, r *http.Request) {
// among by charging_state ranking — see decorateWithVehicle.
func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) {
if s.deps.Loadpoints == nil {
writeJSON(w, 200, map[string]any{"enabled": false, "loadpoints": []any{}})
writeJSON(w, 200, map[string]any{"enabled": false, "loadpoints": []any{}, "vehicle_limit_goal_supported": true})
return
}
states := s.deps.Loadpoints.States()
Expand All @@ -3440,8 +3440,9 @@ func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) {
s.decorateLoadpointsWithBatteryBoost(states)
s.decorateLoadpointsWithPlan(states)
writeJSON(w, 200, map[string]any{
"enabled": true,
"loadpoints": states,
"enabled": true,
"vehicle_limit_goal_supported": true,
"loadpoints": states,
})
}

Expand Down
22 changes: 22 additions & 0 deletions go/internal/api/api_loadpoint_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,25 @@ func waitForSchedulePlan(t *testing.T, svc *mpc.Service) {
time.Sleep(time.Millisecond)
}
}

func TestVehicleLimitGoalCapabilityAndSchedule(t *testing.T) {
srv, mgr, _ := newScheduleServer(t)
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil))
var response map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil || response["vehicle_limit_goal_supported"] != true {
t.Fatal(rr.Body.String(), err)
}
saved := loadpoint.Schedule{}
mgr.SetScheduleSaver(func(_ string, s loadpoint.Schedule) error { saved = s; return nil })
rr = putSchedule(t, srv, "garage", `{"soc":0.8,"finish_at_vehicle_limit":true,"time_of_day_min_utc":300,"recurring":true}`)
st, _ := mgr.State("garage")
if rr.Code != 200 || !saved.FinishAtVehicleLimit || saved.SoC != .8 || !st.FinishAtVehicleLimit || st.TargetSoC != 1 {
t.Fatal(rr.Code, saved, st)
}
rr = putSchedule(t, srv, "garage", `{"soc":0.8,"finish_at_vehicle_limit":false,"time_of_day_min_utc":300,"recurring":true}`)
st, _ = mgr.State("garage")
if rr.Code != 200 || st.FinishAtVehicleLimit || st.TargetSoC != .8 {
t.Fatal(rr.Code, st)
}
}
14 changes: 13 additions & 1 deletion go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ type Controller struct {
// trigger a charge_start command when the EV detached mid-
// session ("Stopped") while we're trying to deliver power.
// nil disables the wake feature.
vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool)
vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool)
vehicleChargeState func(loadpointID string) (VehicleChargeState, bool)

// peakRemainingSurplusW returns the peak PV-minus-load surplus
// expected for the rest of the local day, used by surplus_only
Expand Down Expand Up @@ -1759,6 +1760,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s
cmdW = 0
cmdReason = "no_plan_budget"
}
finishW, finishing := c.vehicleCompletionOffer(lpCfg, now)
if finishing {
cmdW = finishW
cmdReason = "vehicle_limit_completion"
}
// Surplus-only live clamp: regardless of what the MPC slot
// budget said for this 15-minute window, the EV must not
// import grid right now. We smooth the pause/resume decision
Expand Down Expand Up @@ -1839,6 +1845,12 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s
cmdReason = "wake_kick"
}
}
if finishing && finishW == 0 {
cmdW, cmdReason = 0, "vehicle_not_requesting"
if state, ok := c.manager.State(lpCfg.ID); ok && state.GoalComplete {
cmdReason = "vehicle_complete"
}
}
// 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
113 changes: 92 additions & 21 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package loadpoint

import (
"crypto/rand"
"sort"
"sync"
"time"
Expand Down Expand Up @@ -128,20 +129,23 @@ type State struct {
// ChargingDeclined is a sustained vehicle-side refusal, not a battery level.
ChargingDeclined bool `json:"charging_declined"`
// SoCRetention reports whether the confirmed estimate can survive restart.
EnergySource string `json:"energy_source,omitempty"`
EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"`
PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"`
PowerUnavailable bool `json:"power_unavailable,omitempty"`
SoCRetention string `json:"soc_retention,omitempty"`
ID string `json:"id"`
DriverName string `json:"driver_name"`
PluggedIn bool `json:"plugged_in"`
CurrentSoC float64 `json:"current_soc"` // observed or estimated
CurrentPowerW float64 `json:"current_power_w"` // actual draw (site sign: + = charging)
DeliveredWhSession float64 `json:"delivered_wh_session"` // since plug-in
TargetSoC float64 `json:"target_soc"` // user intent
TargetTime time.Time `json:"target_time,omitempty"` // user intent
UpdatedAtMs int64 `json:"updated_at_ms"`
EnergySource string `json:"energy_source,omitempty"`
EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"`
PowerUpdatedAtMs int64 `json:"power_updated_at_ms,omitempty"`
PowerUnavailable bool `json:"power_unavailable,omitempty"`
SoCRetention string `json:"soc_retention,omitempty"`
ID string `json:"id"`
DriverName string `json:"driver_name"`
PluggedIn bool `json:"plugged_in"`
CurrentSoC float64 `json:"current_soc"` // observed or estimated
CurrentPowerW float64 `json:"current_power_w"` // actual draw (site sign: + = charging)
DeliveredWhSession float64 `json:"delivered_wh_session"` // since plug-in
FinishAtVehicleLimit bool `json:"finish_at_vehicle_limit,omitempty"`
GoalComplete bool `json:"goal_complete,omitempty"`
GoalRetention string `json:"goal_retention,omitempty"`
TargetSoC float64 `json:"target_soc"` // user intent
TargetTime time.Time `json:"target_time,omitempty"` // user intent
UpdatedAtMs int64 `json:"updated_at_ms"`

// Vehicle-side telemetry, populated by the API layer from the most
// recent DerVehicle reading whose charging_state indicates a likely
Expand Down Expand Up @@ -350,13 +354,21 @@ type loadpointRuntime struct {
completionNotified bool
Config

pluggedIn bool
currentSoC float64
currentPowerW float64
deliveredWhSession float64
targetSoC float64
targetTime time.Time
updatedAtMs int64
pluggedIn bool
connectionObservedAt time.Time
currentSoC float64
currentPowerW float64
deliveredWhSession float64
finishAtVehicleLimit bool
finishGoalCompleted bool
finishGoalSavedCompleted bool
finishGoalChecked bool
finishGoalExplicit bool
finishGoalSaved time.Time
finishGoalRetention string
targetSoC float64
targetTime time.Time
updatedAtMs int64

// Plug-in anchor: the SoC we believe the vehicle was at when
// this session began. Persisted across Observe() calls so SoC
Expand Down Expand Up @@ -547,6 +559,7 @@ func (m *Manager) Load(cfgs []Config) {
// SoC reference and reset the estimate back to
// PluginSoC even though delivered_wh has grown.
lp.pluggedIn = existing.pluggedIn
lp.connectionObservedAt = existing.connectionObservedAt
lp.currentSoC = existing.currentSoC
lp.currentPowerW = existing.currentPowerW
lp.deliveredWhSession = existing.deliveredWhSession
Expand All @@ -556,6 +569,13 @@ func (m *Manager) Load(cfgs []Config) {
lp.lastSavedEnergyWh = existing.lastSavedEnergyWh
lp.lastSavedEnergyAt = existing.lastSavedEnergyAt
lp.targetSoC = existing.targetSoC
lp.finishAtVehicleLimit = existing.finishAtVehicleLimit
lp.finishGoalCompleted = existing.finishGoalCompleted
lp.finishGoalSavedCompleted = existing.finishGoalSavedCompleted
lp.finishGoalChecked = existing.finishGoalChecked
lp.finishGoalExplicit = existing.finishGoalExplicit
lp.finishGoalSaved = existing.finishGoalSaved
lp.finishGoalRetention = existing.finishGoalRetention
lp.targetTime = existing.targetTime
lp.updatedAtMs = existing.updatedAtMs
lp.sessionPluginSoC = existing.sessionPluginSoC
Expand Down Expand Up @@ -726,6 +746,13 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64
fired = append(fired, events.ChargingConnected{LoadpointID: id, At: now})
}
if pluggedIn && !lp.pluggedIn {
lp.connectionObservedAt = now
if lp.finishAtVehicleLimit && lp.schedule.Recurring && lp.finishGoalCompleted {
lp.finishGoalCompleted = false
lp.targetSoC = 1
lp.targetTime = lp.schedule.NextDeadlineUTC(now, m.loc)
lp.lastRolledFor = lp.targetTime
}
// Plug-in transition: seed the session anchor and clear any
// session-completion latched from a prior session.
anchor := lp.PluginSoC
Expand Down Expand Up @@ -901,6 +928,8 @@ func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool {
lp.chargingDeclined = false
lp.notRequestingSince = time.Time{}
}
lp.finishAtVehicleLimit = false
lp.finishGoalCompleted = false
lp.targetSoC = units.ClampFraction(soc)
lp.targetTime = targetTime
return true
Expand Down Expand Up @@ -1134,6 +1163,9 @@ func (lp *loadpointRuntime) snapshot() State {
CurrentPowerW: lp.currentPowerW,
DeliveredWhSession: lp.deliveredWhSession,
TargetSoC: lp.targetSoC,
FinishAtVehicleLimit: lp.finishAtVehicleLimit,
GoalRetention: lp.finishGoalRetention,
GoalComplete: lp.finishGoalCompleted,
TargetTime: lp.targetTime,
UpdatedAtMs: lp.updatedAtMs,
MinChargeW: lp.MinChargeW,
Expand Down Expand Up @@ -1203,6 +1235,17 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) {
return false, nil
}
s.Normalize()
if s.FinishAtVehicleLimit {
s.IntentID = rand.Text()
if !s.Recurring {
s.FirstDeadlineMS = s.NextDeadlineUTC(m.now(), m.loc).UnixMilli()
} else {
s.FirstDeadlineMS = 0
}
} else {
s.IntentID = ""
s.FirstDeadlineMS = 0
}
// The weekday mask is 7 bits; a stray high bit from a future
// client is dropped rather than left to confuse the roll.
s.Days &= 0x7F
Expand All @@ -1220,6 +1263,10 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) {
lp.notRequestingSince = time.Time{}
}
lp.schedule = s
lp.finishGoalCompleted = false
lp.finishGoalSavedCompleted = false
lp.finishGoalExplicit = true
lp.finishGoalSaved = time.Time{}
// Force RollSchedules to re-evaluate on next call — operator just
// changed the contract so any previous idempotence cache is stale.
lp.lastRolledFor = time.Time{}
Expand All @@ -1234,6 +1281,7 @@ func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) {
// non-recurring saves.
lp.targetTime = time.Time{}
lp.targetSoC = 0
lp.finishAtVehicleLimit = false
return true, nil
}

Expand Down Expand Up @@ -1317,13 +1365,32 @@ func (m *Manager) RollSchedules(now time.Time) {
if s.Empty() {
continue
}
// An unfinished vehicle-limit goal remains due after its deadline.
// Moving it to tomorrow would defer the remaining charge again.
if lp.finishGoalCompleted {
if !s.Recurring || lp.targetTime.After(now) {
continue
}
lp.finishGoalCompleted = false
lp.targetTime = time.Time{}
}
if lp.finishAtVehicleLimit && lp.pluggedIn && !lp.chargingDeclined && !lp.targetTime.IsZero() {
continue
}
next := s.NextDeadlineUTC(now, m.loc)
if s.FinishAtVehicleLimit && !s.Recurring && s.FirstDeadlineMS > 0 {
next = time.UnixMilli(s.FirstDeadlineMS)
}
if s.Recurring {
if !lp.targetTime.IsZero() && lp.targetTime.After(now) {
continue
}
lp.targetTime = next
lp.finishAtVehicleLimit = s.FinishAtVehicleLimit
lp.targetSoC = s.SoC
if s.FinishAtVehicleLimit {
lp.targetSoC = 1
}
lp.lastRolledFor = next
continue
}
Expand All @@ -1332,7 +1399,11 @@ func (m *Manager) RollSchedules(now time.Time) {
// re-save with a non-recurring schedule re-seeds.
if lp.lastRolledFor.IsZero() {
lp.targetTime = next
lp.finishAtVehicleLimit = s.FinishAtVehicleLimit
lp.targetSoC = s.SoC
if s.FinishAtVehicleLimit {
lp.targetSoC = 1
}
lp.lastRolledFor = next
}
}
Expand Down
Loading