-
Notifications
You must be signed in to change notification settings - Fork 10
feat(ev): say why the charger is (not) charging and when it will #1004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an operator clears a schedule that previously set
GridDeferred, the MPC loadpoint-spec loop skips that loadpoint at its schedule gate before callingSetGridDeferred, so the controller's map retainstrue. 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 👍 / 👎.