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
5 changes: 5 additions & 0 deletions .changeset/charger-steps-planning-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Limit charging steps to the charger's max power when loading, saving and planning, and show a warning. Show the latest planning failure in Plan instead of leaving it on Preparing.
16 changes: 16 additions & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"net/http"
"path/filepath"
"runtime"
"slices"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -1681,6 +1682,8 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) {
"status": "ok",
"restart_required": len(restartReasons) > 0,
"restart_reasons": restartReasons,
"warnings": newCfg.LoadpointWarnings,
"loadpoints": newCfg.Loadpoints,
})
}

Expand Down Expand Up @@ -2606,6 +2609,19 @@ func (s *Server) handleMPCPlan(w http.ResponseWriter, r *http.Request) {
"last_replan_reason": snapshot.Reason,
"replanning": snapshot.Pending,
"outdated": snapshot.Outdated,
"error": snapshot.Error,
"warnings": snapshot.Warnings,
}
if s.deps.Cfg != nil && s.deps.CfgMu != nil {
s.deps.CfgMu.RLock()
warnings := append([]string(nil), s.deps.Cfg.LoadpointWarnings...)
s.deps.CfgMu.RUnlock()
for _, warning := range snapshot.Warnings {
if !slices.Contains(warnings, warning) {
warnings = append(warnings, warning)
}
}
meta["warnings"] = warnings
}
if plan == nil {
writeJSON(w, 200, map[string]any{"enabled": true, "plan": nil, "meta": meta})
Expand Down
73 changes: 73 additions & 0 deletions go/internal/api/api_charger_steps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/mpc"
)

func TestPostConfigSavesLimitedStepsAndReturnsWarning(t *testing.T) {
srv, _, cfg := postConfigServer(t, nil)
srv.deps.SaveConfig = config.SaveAtomic
body := strings.TrimSpace(firstSiteMeterConfig)
body = body[:len(body)-1] + `,"loadpoints":[{"id":"Easee","min_charge_w":4800,"max_charge_w":11000,"allowed_steps_w":[0,4800,10400,11100]}]}`
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("save rejected recoverable config: %s", rr.Body.String())
}
var response struct {
Warnings []string
Loadpoints []config.Loadpoint
}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Warnings) != 1 || len(response.Loadpoints) != 1 {
t.Fatalf("missing save feedback: %s", rr.Body.String())
}
if response.Loadpoints[0].AllowedStepsW[3] != 11000 {
t.Fatal("save response did not return the corrected steps")
}
if got := cfg.Loadpoints[0].AllowedStepsW[3]; got != 11000 {
t.Fatalf("live config step=%v", got)
}
saved, err := config.Load(srv.deps.ConfigPath)
if err != nil {
t.Fatal(err)
}
if saved.Loadpoints[0].AllowedStepsW[3] != 11000 {
t.Fatal("invalid step persisted")
}
srv.deps.MPC = &mpc.Service{}
rr = httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/mpc/plan", nil))
if !strings.Contains(rr.Body.String(), "11000 W") {
t.Fatalf("Plan lost correction warning: %s", rr.Body.String())
}
}

func TestMPCPlanReturnsActualPlanningFailure(t *testing.T) {
svc := &mpc.Service{SiteEconomics: mpc.SiteEconomics{FuseMaxW: -1}}
svc.Replan(context.Background())
rr := httptest.NewRecorder()
New(&Deps{MPC: svc}).Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/mpc/plan", nil))
var response struct {
Meta struct{ Error string }
Plan *mpc.Plan
}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Plan != nil || !strings.Contains(response.Meta.Error, "fuse_max_w") {
t.Fatalf("actual failure missing: %s", rr.Body.String())
}
}
9 changes: 8 additions & 1 deletion go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ type Config struct {
// still boot, or the operator loses the UI they would fix it with. The
// write path (Settings save, bootstrap) never populates this — it calls
// Validate directly and stays strict. Never serialized.
LoadWarnings []string `yaml:"-" json:"-"`
LoadWarnings []string `yaml:"-" json:"-"`
LoadpointWarnings []string `yaml:"-" json:"-"`
// Retired names settings of removed features that loading dropped so
// they cannot steer the site. Startup warns once for each;
// DropRetiredSettings deletes them from stored settings.
Expand Down Expand Up @@ -1915,6 +1916,9 @@ func isLegacyDefaultDriverRepository(repo DriverRepositorySource) bool {
// Validate ensures the config is internally consistent and safe to run with.
func (c *Config) Validate() error {
c.NormalizeUnits()
if err := c.normalizeLoadpointSteps(); err != nil {
return err
}
if c.State != nil && c.State.ColdRetentionDays < 0 {
return fmt.Errorf("state.cold_retention_days must be >= 0, got %d", c.State.ColdRetentionDays)
}
Expand Down Expand Up @@ -2306,6 +2310,9 @@ func SaveAtomic(path string, c *Config) error {
}

func saveAtomic(w durableWriter, path string, c *Config) error {
if err := c.normalizeLoadpointSteps(); err != nil {
return err
}
// Driver paths are resolved to absolute-ish paths at Load() time.
// Convert them back to config-relative before writing so that
// repeated save cycles don't accumulate extra "../" prefixes.
Expand Down
38 changes: 38 additions & 0 deletions go/internal/config/loadpoint_steps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package config

import (
"fmt"
"math"
"slices"

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

func (c *Config) normalizeLoadpointSteps() error {
// Own the slice before repairing it: config reload keeps the old snapshot.
loadpoints := slices.Clone(c.Loadpoints)
for i := range loadpoints {
lp := &loadpoints[i]
if math.IsNaN(lp.MaxChargeW) || math.IsInf(lp.MaxChargeW, 0) || lp.MaxChargeW < 0 {
return fmt.Errorf("charger %q: max charging power must be finite and non-negative", lp.ID)
}
if math.IsNaN(lp.MinChargeW) || math.IsInf(lp.MinChargeW, 0) || lp.MinChargeW < 0 || lp.MinChargeW > lp.MaxChargeW {
return fmt.Errorf("charger %q: min charging power must be between 0 and max charging power", lp.ID)
}
for _, step := range lp.AllowedStepsW {
if math.IsNaN(step) || math.IsInf(step, 0) || step < 0 {
return fmt.Errorf("charger %q: charging steps must be finite and non-negative", lp.ID)
}
}
steps, limited := units.LimitChargeSteps(lp.AllowedStepsW, lp.MaxChargeW)
lp.AllowedStepsW = steps
if limited {
warning := fmt.Sprintf("Charger %q: charging steps above the %g W limit were reduced to %g W.", lp.ID, lp.MaxChargeW, lp.MaxChargeW)
if !slices.Contains(c.LoadpointWarnings, warning) {
c.LoadpointWarnings = append(c.LoadpointWarnings, warning)
}
}
}
c.Loadpoints = loadpoints
return nil
}
98 changes: 98 additions & 0 deletions go/internal/config/loadpoint_steps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package config

import (
"encoding/json"
"math"
"path/filepath"
"reflect"
"strings"
"testing"

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

func TestLoadpointStepsAreLimitedOnLoadAndSave(t *testing.T) {
cfg, err := Parse([]byte(minimalYAML+`
loadpoints:
- id: Easee
driver_name: easee_cloud
min_charge_w: 4800
max_charge_w: 11000
allowed_steps_w: [0, 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100, 12000]
`), t.TempDir())
if err != nil {
t.Fatal(err)
}
want := []float64{0, 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11000}
if !reflect.DeepEqual(cfg.Loadpoints[0].AllowedStepsW, want) {
t.Fatal(cfg.Loadpoints[0].AllowedStepsW)
}
if len(cfg.LoadpointWarnings) != 1 || !strings.Contains(cfg.LoadpointWarnings[0], `"Easee"`) {
t.Fatal(cfg.LoadpointWarnings)
}
old := cfg.Loadpoints[0].AllowedStepsW
cfg.Loadpoints[0].MaxChargeW = 10000
path := filepath.Join(t.TempDir(), "config.yaml")
if err := SaveAtomic(path, cfg); err != nil {
t.Fatal(err)
}
if old[len(old)-1] != 11000 {
t.Fatal("repair mutated an earlier snapshot")
}
reloaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
for _, step := range reloaded.Loadpoints[0].AllowedStepsW {
if step > 10000 {
t.Fatalf("persisted step %v exceeds max", step)
}
}
if len(reloaded.LoadpointWarnings) != 0 {
t.Fatal("corrected persisted config still warns", reloaded.LoadpointWarnings)
}
}

func TestLoadpointStepsAreLimitedInStoredSettings(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "state.db")
st, err := state.Open(database)
if err != nil {
t.Fatal(err)
}
defer st.Close()
cfg, err := Parse([]byte(minimalYAML), dir)
if err != nil {
t.Fatal(err)
}
cfg.ConfigDatabase = database
cfg.Loadpoints = []Loadpoint{{ID: "Easee", MaxChargeW: 11000, AllowedStepsW: []float64{0, 11100}}}
if err := SaveStored(st, filepath.Join(dir, "config.yaml"), cfg); err != nil {
t.Fatal(err)
}
doc, found, err := st.Configuration()
if err != nil || !found {
t.Fatalf("stored settings: found=%v err=%v", found, err)
}
// Inspect the stored bytes before load-time repair could hide a bad save.
var saved storedSettings
if err := json.Unmarshal(doc.Document, &saved); err != nil {
t.Fatal(err)
}
if saved.Config.Loadpoints[0].AllowedStepsW[1] != 11000 || len(cfg.LoadpointWarnings) != 1 {
t.Fatalf("stored steps=%v warnings=%v", saved.Config.Loadpoints[0].AllowedStepsW, cfg.LoadpointWarnings)
}
}

func TestLoadpointStepsKeepInvalidPhysicsVisible(t *testing.T) {
for _, invalid := range []float64{-1, math.NaN(), math.Inf(1)} {
cfg, err := Parse([]byte(minimalYAML), t.TempDir())
if err != nil {
t.Fatal(err)
}
cfg.Loadpoints = []Loadpoint{{ID: "Easee", MaxChargeW: 11000, AllowedStepsW: []float64{invalid}}}
if err := cfg.Validate(); err == nil {
t.Fatalf("accepted invalid step %v", invalid)
}
}
}
50 changes: 50 additions & 0 deletions go/internal/mpc/planning_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package mpc

import (
"fmt"
"log/slog"

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

func (s *Service) failedReplan(request replanRequest, message string) *Plan {
s.mu.Lock()
if request.generation == s.latestReplanGeneration && !request.wasCanceledByService() {
s.lastPlanningError = message
}
plan := s.last
s.mu.Unlock()
return plan
}

func limitPlanningLoadpointSteps(p *Params) []string {
var warnings []string
copySpec := func(lp *LoadpointSpec) *LoadpointSpec {
if lp == nil {
return nil
}
copy := *lp
steps, limited := units.LimitChargeSteps(lp.AllowedStepsW, lp.MaxChargeW)
copy.AllowedStepsW = steps
if limited {
warning := fmt.Sprintf("Charger %q: charging steps above the %g W limit were reduced to %g W.", lp.ID, lp.MaxChargeW, lp.MaxChargeW)
warnings = append(warnings, warning)
slog.Warn("mpc: charging steps limited to max charging power", "charger", lp.ID, "max_charge_w", lp.MaxChargeW)
}
return &copy
}
// The legacy single-loadpoint fallback must use the same corrected physics.
original := p.Loadpoint
p.Loadpoints = append([]*LoadpointSpec(nil), p.Loadpoints...)
for i, lp := range p.Loadpoints {
p.Loadpoints[i] = copySpec(lp)
if lp == original {
p.Loadpoint = p.Loadpoints[i]
original = nil
}
}
if original != nil {
p.Loadpoint = copySpec(original)
}
return warnings
}
Loading
Loading