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
14 changes: 14 additions & 0 deletions .changeset/ev-plan-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"ftw": minor
---

The EV modal now says why the charger is or is not charging, and when it
will: the next planned charge window from the active plan ("Charging
planned 02:15–06:30, ~18 kWh"), an explicit "waiting for tomorrow's
prices — PV surplus only until then" state when grid-funded planning is
deferred past the published price horizon, "charger offers X kW but the
car isn't drawing" with the charger's own reason when the vehicle
declines, and a plain warning when nothing (schedule, PV-only, Start)
will ever start a charge. GET /api/loadpoints carries the new fields:
`plan_next_start_ms` / `plan_next_end_ms` / `plan_next_wh` /
`plan_total_wh`, `grid_deferred`, and `commanded_w` / `commanded_known`.
1 change: 1 addition & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3482,6 +3482,7 @@ func (s *Server) handleLoadpoints(w http.ResponseWriter, r *http.Request) {
}
s.decorateLoadpointsWithManual(states)
s.decorateLoadpointsWithBatteryBoost(states)
s.decorateLoadpointsWithPlan(states)
writeJSON(w, 200, map[string]any{
"enabled": true,
"loadpoints": states,
Expand Down
44 changes: 44 additions & 0 deletions go/internal/api/api_loadpoint_plan.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package api

import (
"time"

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

// Planner-visibility decoration for GET /api/loadpoints. Fills the
// fields that answer the operator's first question at plug-in — "when
// will it charge, and if not, why not?":
//
// - the next window in which the active MPC plan allocates charge
// energy to the loadpoint (so the UI can say "charging planned
// 02:15–06:30" instead of sitting silent until the cheap slots
// arrive, which teaches operators to press Start and lose the
// plan);
// - whether grid-funded planning is deferred because the deadline
// lies past the published price horizon (which otherwise behaves
// exactly like a PV-only mode nobody chose).
//
// Per the api/CLAUDE.md split convention, this lives in its own file
// and is called from handleLoadpoints in api.go.

// decorateLoadpointsWithPlan mutates states in place.
func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) {
now := time.Now()
for i := range states {
if s.deps.LoadpointCtrl != nil {
states[i].GridDeferred = s.deps.LoadpointCtrl.GridDeferred(states[i].ID)
Comment on lines +29 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear deferred state when a schedule disappears

When an operator clears a schedule that previously set GridDeferred, the MPC loadpoint-spec loop skips that loadpoint at its schedule gate before calling SetGridDeferred, so the controller's map retains true. Exporting that stale value here makes the modal continue saying it is waiting for tomorrow's prices indefinitely instead of reaching the no-schedule state; clear deferral for loadpoints with no active target or derive this field from current planning inputs.

Useful? React with 👍 / 👎.

}
if s.deps.MPC == nil {
continue
}
windows, totalWh := s.deps.MPC.LoadpointPlanWindows(states[i].ID, now, 1)
if len(windows) == 0 {
continue
}
states[i].PlanNextStartMs = windows[0].Start.UnixMilli()
states[i].PlanNextEndMs = windows[0].End.UnixMilli()
states[i].PlanNextWh = windows[0].EnergyWh
states[i].PlanTotalWh = totalWh
}
}
8 changes: 8 additions & 0 deletions go/internal/loadpoint/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,14 @@ func (c *Controller) SetGridDeferred(lpID string, deferred bool) {
}
}

// GridDeferred reports whether MPC has deferred grid-funded planning
// for this loadpoint (target deadline past the published price
// horizon). Read by the API layer so the deferral is visible to the
// operator instead of looking like a PV-only mode nobody chose.
func (c *Controller) GridDeferred(lpID string) bool {
return c.gridDeferredFor(lpID)
}

// gridDeferredFor reads the per-LP deferral flag set by main.go's MPC
// spec builder. Read-only accessor used inside surplusActive.
func (c *Controller) gridDeferredFor(lpID string) bool {
Expand Down
28 changes: 28 additions & 0 deletions go/internal/loadpoint/loadpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,32 @@ type State struct {
// zero fields) so the UI can rely on a stable shape — clients
// detect "no schedule" via Schedule.Empty() / soc_pct === 0.
Schedule Schedule `json:"schedule"`

// CommandedW is what the controller last ordered this loadpoint to
// deliver, after every clamp. CommandedKnown separates "ordered
// zero" from "no dispatch tick has run yet". The UI reads the pair
// to tell "the box is offering power the car is not taking" from
// "the box is pausing on purpose".
CommandedW float64 `json:"commanded_w"`
CommandedKnown bool `json:"commanded_known"`

// GridDeferred is true while MPC has deferred grid-funded planning
// because the target deadline lies past the published price
// horizon — the loadpoint behaves surplus-only until tomorrow's
// prices land. Without this flag that deferral is invisible and
// reads as "PV only that nobody chose". Populated by the API layer
// from the loadpoint controller.
GridDeferred bool `json:"grid_deferred"`

// PlanNextStartMs/PlanNextEndMs/PlanNextWh describe the next
// window in which the active plan allocates charge energy to this
// loadpoint; PlanTotalWh is everything the plan still intends to
// deliver over the horizon. All zero when the planner has no
// allocation. Populated by the API layer from the MPC plan.
PlanNextStartMs int64 `json:"plan_next_start_ms,omitempty"`
PlanNextEndMs int64 `json:"plan_next_end_ms,omitempty"`
PlanNextWh float64 `json:"plan_next_wh,omitempty"`
PlanTotalWh float64 `json:"plan_total_wh,omitempty"`
}

// Manager holds the running set of loadpoints. Thread-safe.
Expand Down Expand Up @@ -872,6 +898,8 @@ func (lp *loadpointRuntime) snapshot() State {
Schedule: lp.schedule,
SoCSource: lp.socSource,
VehicleName: lp.vehicleName,
CommandedW: lp.commandedW,
CommandedKnown: lp.commandedKnown,
}
}

Expand Down
94 changes: 94 additions & 0 deletions go/internal/mpc/loadpoint_planwindows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package mpc

import (
"testing"
"time"
)

// planWithActions builds a minimal fresh Plan whose Actions start at
// `start` in 15-minute slots with the given per-slot loadpoint watts
// under id "garage" (multi-LP map shape).
func planWithActions(start time.Time, lpW []float64) *Plan {
actions := make([]Action, len(lpW))
for i, w := range lpW {
actions[i] = Action{
SlotStartMs: start.Add(time.Duration(i) * 15 * time.Minute).UnixMilli(),
SlotLenMin: 15,
}
if w > 0 {
actions[i].LoadpointPowerW = map[string]float64{"garage": w}
}
}
return &Plan{GeneratedAtMs: time.Now().UnixMilli(), Actions: actions}
}

// TestLoadpointPlanWindowsMergesContiguousSlots asserts that adjacent
// allocated slots come back as one window, past slots are dropped, a
// window cap keeps the Wh total intact, and the current slot is
// included with its full bounds.
func TestLoadpointPlanWindowsMergesContiguousSlots(t *testing.T) {
now := time.Now().UTC().Truncate(15 * time.Minute)
start := now.Add(-30 * time.Minute)
// Slots: [past 11 kW] [past 0] [current 4 kW] [4 kW] [0] [11 kW]
svc := &Service{last: planWithActions(start, []float64{11000, 0, 4000, 4000, 0, 11000})}

windows, totalWh := svc.LoadpointPlanWindows("garage", now.Add(1*time.Minute), 1)
if len(windows) != 1 {
t.Fatalf("want 1 window (max=1), got %d: %+v", len(windows), windows)
}
w := windows[0]
if !w.Start.Equal(now) {
t.Errorf("window start: want %v (current slot start), got %v", now, w.Start)
}
if !w.End.Equal(now.Add(30 * time.Minute)) {
t.Errorf("window end: want %v, got %v", now.Add(30*time.Minute), w.End)
}
if w.EnergyWh != 2000 {
t.Errorf("window Wh: want 2000 (2×4000 W×0.25 h), got %v", w.EnergyWh)
}
// Total covers the capped-away 11 kW slot too: 2000 + 2750.
if totalWh != 4750 {
t.Errorf("total Wh: want 4750, got %v", totalWh)
}

// Uncapped: the far 11 kW slot becomes its own second window.
windows, _ = svc.LoadpointPlanWindows("garage", now.Add(1*time.Minute), 0)
if len(windows) != 2 {
t.Fatalf("want 2 windows uncapped, got %d: %+v", len(windows), windows)
}
if windows[1].EnergyWh != 2750 {
t.Errorf("second window Wh: want 2750, got %v", windows[1].EnergyWh)
}
}

// TestLoadpointPlanWindowsLegacySingleLP asserts the legacy plan shape
// (Action.LoadpointW + Service.lastLoadpointID) is honoured, and that
// an unknown id sees nothing.
func TestLoadpointPlanWindowsLegacySingleLP(t *testing.T) {
now := time.Now().UTC().Truncate(15 * time.Minute)
p := &Plan{GeneratedAtMs: time.Now().UnixMilli(), Actions: []Action{
{SlotStartMs: now.UnixMilli(), SlotLenMin: 15, LoadpointW: 6000},
}}
svc := &Service{last: p, lastLoadpointID: "carport"}

windows, totalWh := svc.LoadpointPlanWindows("carport", now, 0)
if len(windows) != 1 || totalWh != 1500 {
t.Fatalf("legacy shape: want 1 window / 1500 Wh, got %+v / %v", windows, totalWh)
}
if windows, totalWh = svc.LoadpointPlanWindows("other", now, 0); len(windows) != 0 || totalWh != 0 {
t.Fatalf("unknown id: want nothing, got %+v / %v", windows, totalWh)
}
}

// TestLoadpointPlanWindowsStalePlan asserts a plan older than
// MaxPlanAge promises no start times — same cutoff SlotDirectiveAt
// applies before the control loop falls back.
func TestLoadpointPlanWindowsStalePlan(t *testing.T) {
now := time.Now().UTC().Truncate(15 * time.Minute)
p := planWithActions(now, []float64{4000})
p.GeneratedAtMs = time.Now().Add(-MaxPlanAge - time.Minute).UnixMilli()
svc := &Service{last: p}
if windows, totalWh := svc.LoadpointPlanWindows("garage", now, 0); len(windows) != 0 || totalWh != 0 {
t.Fatalf("stale plan: want nothing, got %+v / %v", windows, totalWh)
}
}
65 changes: 65 additions & 0 deletions go/internal/mpc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,71 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) {
return SlotDirective{}, false
}

// PlanWindow is one contiguous run of plan slots in which the active
// plan allocates charge energy to a loadpoint. Exposed so the API can
// answer the operator's first question at plug-in — "when will it
// charge?" — without the UI re-deriving the plan's loadpoint columns.
type PlanWindow struct {
Start time.Time
End time.Time
EnergyWh float64
}

// LoadpointPlanWindows returns the contiguous windows, ending after
// `now`, in which the active plan allocates charge energy to loadpoint
// `id`, plus the total Wh the plan still intends to deliver across the
// horizon. A window already underway is included with its full slot
// bounds. At most `max` windows are returned (0 = unlimited); the Wh
// total always covers every remaining slot. Returns (nil, 0) when
// there is no fresh plan — same MaxPlanAge cutoff as SlotDirectiveAt,
// because a stale plan must not promise start times.
func (s *Service) LoadpointPlanWindows(id string, now time.Time, max int) ([]PlanWindow, float64) {
if s == nil || id == "" {
return nil, 0
}
s.mu.RLock()
p := s.last
legacyID := s.lastLoadpointID
s.mu.RUnlock()
if p == nil || time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge {
return nil, 0
}
nowMs := now.UnixMilli()
var windows []PlanWindow
var totalWh float64
for _, a := range p.Actions {
endMs := a.SlotStartMs + int64(a.SlotLenMin)*60*1000
if endMs <= nowMs {
continue
}
powerW := 0.0
if len(a.LoadpointPowerW) > 0 {
powerW = a.LoadpointPowerW[id]
} else if id == legacyID {
powerW = a.LoadpointW
}
if powerW <= 0 {
continue
}
wh := powerW * float64(a.SlotLenMin) / 60.0
totalWh += wh
start := time.UnixMilli(a.SlotStartMs)
end := time.UnixMilli(endMs)
if n := len(windows); n > 0 && windows[n-1].End.Equal(start) {
windows[n-1].End = end
windows[n-1].EnergyWh += wh
continue
}
if max > 0 && len(windows) == max {
// Window cap reached: keep accumulating the Wh total,
// just stop growing the list.
continue
}
windows = append(windows, PlanWindow{Start: start, End: end, EnergyWh: wh})
}
return windows, totalWh
}

// livePVSurplusSoCCap returns a quantified ceiling for moving later
// grid-funded charging into live PV in the current slot. This is deliberately
// derived from decisions already present in the plan rather than a blanket
Expand Down
Loading