diff --git a/.changeset/ev-plan-visibility.md b/.changeset/ev-plan-visibility.md new file mode 100644 index 000000000..2c60ce8d9 --- /dev/null +++ b/.changeset/ev-plan-visibility.md @@ -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`. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index eb9a55ad3..198a8a90c 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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, diff --git a/go/internal/api/api_loadpoint_plan.go b/go/internal/api/api_loadpoint_plan.go new file mode 100644 index 000000000..d8bea9b02 --- /dev/null +++ b/go/internal/api/api_loadpoint_plan.go @@ -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) + } + 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 + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index cfa3319a8..6298b1658 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -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 { diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 9632fc680..778d904b6 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -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. @@ -872,6 +898,8 @@ func (lp *loadpointRuntime) snapshot() State { Schedule: lp.schedule, SoCSource: lp.socSource, VehicleName: lp.vehicleName, + CommandedW: lp.commandedW, + CommandedKnown: lp.commandedKnown, } } diff --git a/go/internal/mpc/loadpoint_planwindows_test.go b/go/internal/mpc/loadpoint_planwindows_test.go new file mode 100644 index 000000000..d08dd717f --- /dev/null +++ b/go/internal/mpc/loadpoint_planwindows_test.go @@ -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) + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 5189cfc8a..d842e47f4 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -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 diff --git a/web/app.js b/web/app.js index 0ef4c176e..4a9644c5a 100644 --- a/web/app.js +++ b/web/app.js @@ -2589,6 +2589,70 @@ evModalBody.appendChild(p); } + function evFmtClock(ms) { + // 24-hour clock, matching plan-brief.js's formatClock — the rest of + // the plan UI speaks 24 h regardless of browser locale. + var d = new Date(ms); + return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); + } + + // renderEvPlanStatus answers the question the status table can't: + // "why isn't it charging right now, and when will it?" Field + // experience: a car plugged in against a schedule sits at 0 W until + // the cheap slots arrive, the modal looks dead, and the operator + // presses Start — which overrides the plan for the whole session. + // One honest sentence here is what prevents that. Returns null when + // there is nothing worth saying (no loadpoint, or unplugged — the + // schedule note covers that case). + function renderEvPlanStatus(lp, d) { + if (!lp || !lp.plugged_in) return null; + var text = null; + var tone = "var(--text-dim)"; + var kwPlanned = lp.plan_total_wh > 0 ? " ~" + (lp.plan_total_wh / 1000).toFixed(1) + " kWh planned." : ""; + var winActive = lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; + var charging = (lp.current_power_w || 0) >= 100; + var hasSchedule = lp.schedule && lp.schedule.soc > 0; + if (lp.manual_active) { + text = "Manual charge pinned at " + formatW(lp.manual_charge_w || 0) + + " — plan and PV logic are off until Stop or unplug."; + } else if (charging) { + text = winActive + ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned + : "Charging."; + } else if (lp.commanded_known && lp.commanded_w > 0) { + text = "Charger offers " + formatW(lp.commanded_w) + + " but the car isn't drawing — it may be full or at its own charge limit."; + if (d && d.reason_no_current_label) { + text += " Charger reports: " + d.reason_no_current_label + "."; + } + tone = "var(--text)"; + } else if (lp.grid_deferred) { + text = "Waiting for tomorrow's electricity prices — until they arrive (~13:00) the car charges from PV surplus only."; + } else if (winActive) { + text = "Paused by the box (fuse protection or PV clamp) — charging resumes on its own." + kwPlanned; + } else if (lp.plan_next_start_ms > Date.now()) { + text = "Charging planned " + evFmtClock(lp.plan_next_start_ms) + "–" + + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned + + " The planner picks the cheapest hours before your target."; + } else if (lp.surplus_only) { + text = "PV surplus only — charges when solar exceeds house load."; + } else if (!hasSchedule) { + text = "Nothing will start charging: set a schedule, turn on PV only, or press Start."; + tone = "var(--text)"; + } else { + text = "No charge window in the current plan — the target may already be reached."; + } + if (!text) return null; + var p = document.createElement("p"); + p.style.color = tone; + p.style.margin = "0 0 0.6rem 0"; + p.style.padding = "0.35rem 0.5rem"; + p.style.borderLeft = "3px solid var(--accent, #888)"; + p.style.background = "color-mix(in srgb, var(--accent, #888) 8%, transparent)"; + p.textContent = text; + return p; + } + // EV modal sub-elements held across refreshes. The status table is // updated in place on every poll. The tabbed control (PV charging / // Manual / Scheduled) is mounted exactly once per (modal-open × LP) @@ -2598,6 +2662,7 @@ // next poll rebuilds from the new authoritative server state. The // active tab persists across rebuilds via evActiveTab. var statusTableEl = null; + var planStatusEl = null; var evTabsEl = null; var evTabsLpId = null; var schedNeedsRebuild = false; @@ -2646,6 +2711,7 @@ if (!carConnected && !hasLoadpoints) { setEvModalMessage("No EV charger connected"); statusTableEl = null; + planStatusEl = null; evTabsEl = null; evTabsLpId = null; return; @@ -2705,6 +2771,21 @@ matched = lps.loadpoints[0]; } } + // Plan-status strip: one sentence on why the charger is (not) + // charging and when it will. Refreshed on every poll, anchored + // right below the status table so it reads as part of the live + // state rather than the (once-built) tabbed controls. + var freshPlan = renderEvPlanStatus(matched, d); + if (planStatusEl && planStatusEl.parentNode === evModalBody) { + if (freshPlan) { + evModalBody.replaceChild(freshPlan, planStatusEl); + } else { + evModalBody.removeChild(planStatusEl); + } + } else if (freshPlan) { + evModalBody.insertBefore(freshPlan, statusTableEl.nextSibling); + } + planStatusEl = freshPlan; if (matched) { // Build the tabbed control (PV charging / Manual / Scheduled) // exactly once per LP. Polling never rebuilds it — inputs keep diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs new file mode 100644 index 000000000..4882dabab --- /dev/null +++ b/web/ev-plan-status.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); + +// The EV modal's plan-status strip (#1002): one sentence on why the +// charger is (not) charging and when it will. These assertions pin the +// user-facing decision branches so a refactor can't silently drop one. + +test('plan-status strip renders every visibility state', () => { + // Planned window with a start/end clock and the planned energy. + assert.match(source, /Charging planned " \+ evFmtClock\(lp\.plan_next_start_ms\)/); + assert.match(source, /plan_total_wh \/ 1000/); + // Charger offering power the car does not take, with the charger's + // own reason when the driver reports one. + assert.match(source, /but the car isn't drawing/); + assert.match(source, /reason_no_current_label/); + // The silent grid-plan deferral is named instead of looking like a + // PV-only mode nobody chose. + assert.match(source, /Waiting for tomorrow's electricity prices/); + assert.match(source, /grid_deferred/); + // Manual hold names its cost: the plan is off until Stop or unplug. + assert.match(source, /plan and PV logic are off until Stop or unplug/); + // The do-nothing default is called out with the three ways out. + assert.match(source, /set a schedule, turn on PV only, or press Start/); +}); + +test('plan-status strip is wired into the modal refresh', () => { + assert.match(source, /renderEvPlanStatus\(matched, d\)/); + // Updated in place on every poll, like the status table. + assert.match(source, /planStatusEl = freshPlan/); + // And reset when the modal short-circuits to "no charger". + assert.match(source, /statusTableEl = null;\s*\n\s*planStatusEl = null;/); +});