diff --git a/.changeset/charger-steps-planning-errors.md b/.changeset/charger-steps-planning-errors.md new file mode 100644 index 000000000..eb331f0bf --- /dev/null +++ b/.changeset/charger-steps-planning-errors.md @@ -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. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d289fb96b..badf089af 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -19,6 +19,7 @@ import ( "net/http" "path/filepath" "runtime" + "slices" "sort" "strconv" "strings" @@ -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, }) } @@ -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}) diff --git a/go/internal/api/api_charger_steps_test.go b/go/internal/api/api_charger_steps_test.go new file mode 100644 index 000000000..41508c4da --- /dev/null +++ b/go/internal/api/api_charger_steps_test.go @@ -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()) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index a215a7c1b..bfdf98bc8 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -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. @@ -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) } @@ -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. diff --git a/go/internal/config/loadpoint_steps.go b/go/internal/config/loadpoint_steps.go new file mode 100644 index 000000000..90bf29b68 --- /dev/null +++ b/go/internal/config/loadpoint_steps.go @@ -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 +} diff --git a/go/internal/config/loadpoint_steps_test.go b/go/internal/config/loadpoint_steps_test.go new file mode 100644 index 000000000..c5edb20a2 --- /dev/null +++ b/go/internal/config/loadpoint_steps_test.go @@ -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) + } + } +} diff --git a/go/internal/mpc/planning_status.go b/go/internal/mpc/planning_status.go new file mode 100644 index 000000000..6a523e160 --- /dev/null +++ b/go/internal/mpc/planning_status.go @@ -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 © + } + // 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 +} diff --git a/go/internal/mpc/planning_status_test.go b/go/internal/mpc/planning_status_test.go new file mode 100644 index 000000000..4f411ee89 --- /dev/null +++ b/go/internal/mpc/planning_status_test.go @@ -0,0 +1,73 @@ +package mpc + +import ( + "context" + "strings" + "testing" +) + +func TestReplanLimitsChargerStepsAndPublishesWarning(t *testing.T) { + for _, probe := range []bool{false, true} { + svc := newCancellationTestService(t, nil) + lp := validPlanningLoadpoint() + lp.ID = "Easee" + lp.AllowedStepsW = []float64{0, 4800, 5500, 6200, 6900, 7600, 8300, 9000, 9700, 10400, 11100} + if probe { + svc.Loadpoints = func(int) []*LoadpointSpec { return []*LoadpointSpec{lp} } + } else { + svc.Defaults.Loadpoints, svc.Defaults.Loadpoint = []*LoadpointSpec{lp}, lp + } + plan := svc.Replan(context.Background()) + snapshot := svc.PlanSnapshot() + if plan == nil || snapshot.Outdated || snapshot.Error != "" { + t.Fatalf("plan unavailable: %+v", snapshot) + } + if len(snapshot.Warnings) != 1 || !strings.Contains(snapshot.Warnings[0], "11000 W") { + t.Fatal(snapshot.Warnings) + } + if lp.AllowedStepsW[len(lp.AllowedStepsW)-1] != 11100 { + t.Fatal("planning changed the caller's spec") + } + svc.mu.RLock() + steps := append([]float64(nil), svc.lastParams.Loadpoints[0].AllowedStepsW...) + svc.mu.RUnlock() + for _, step := range steps { + if step > 11000 { + t.Fatalf("solver received step %v", step) + } + } + for _, action := range plan.Actions { + if action.LoadpointW > 11000 { + t.Fatalf("planned %v W above max", action.LoadpointW) + } + } + } +} + +func TestPlanningErrorReportsCauseAndClearsOnRecovery(t *testing.T) { + svc := newCancellationTestService(t, nil) + svc.Defaults.MaxDischargeW = -1 + svc.Replan(context.Background()) + failed := svc.PlanSnapshot() + if failed.Pending || !strings.Contains(failed.Error, "max_discharge_w") { + t.Fatalf("missing actual cause: %+v", failed) + } + svc.Defaults.MaxDischargeW = 3000 + svc.Replan(context.Background()) + if got := svc.PlanSnapshot(); got.Error != "" || got.Outdated || got.Plan == nil { + t.Fatalf("recovery did not clear failure: %+v", got) + } +} + +func TestSupersededPlanningFailureCannotReplaceCurrentStatus(t *testing.T) { + svc := newCancellationTestService(t, nil) + old := svc.beginReplan(context.Background(), "old") + newer := svc.beginReplan(context.Background(), "newer") + svc.failedReplan(newer, "current failure") + svc.failedReplan(old, "old failure") + svc.finishReplan(old) + svc.finishReplan(newer) + if got := svc.PlanSnapshot(); got.Error != "current failure" { + t.Fatalf("superseded status won: %+v", got) + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index f397c3fa3..cbdb002fa 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -2,6 +2,7 @@ package mpc import ( "context" + "fmt" "log/slog" "maps" "math" @@ -213,8 +214,10 @@ type Service struct { // before Start; config reload replaces them with UpdateSiteEconomics. SiteEconomics - lastReplanAt time.Time - lastReason string // reason paired with the currently published plan + lastReplanAt time.Time + lastReason string // reason paired with the currently published plan + lastPlanningError string + planningWarnings []string // latestReplanGeneration identifies the newest requested solve. Starting a // newer generation cancels the older request; the generation check remains // the final guard for work that does not stop promptly on context cancel. @@ -461,6 +464,8 @@ type PlanSnapshot struct { Reason string Pending bool Outdated bool + Error string + Warnings []string loadpointID string } @@ -475,6 +480,7 @@ func (s *Service) PlanSnapshot() PlanSnapshot { out := PlanSnapshot{ Plan: s.last, ReplanAt: s.lastReplanAt, Reason: s.lastReason, Pending: outdated && s.activeReplanCancel != nil, Outdated: outdated, + Error: s.lastPlanningError, Warnings: slices.Clone(s.planningWarnings), loadpointID: s.lastLoadpointID, } s.mu.RUnlock() @@ -1368,6 +1374,8 @@ func (s *Service) beginReplanLocked(ctx context.Context, reason string) replanRe cancel() } s.latestReplanGeneration++ + s.lastPlanningError = "" + s.planningWarnings = nil s.activeReplanCancel = serviceCancel s.replanWG.Add(1) return replanRequest{ @@ -1391,11 +1399,19 @@ func (r replanRequest) wasCanceledByService() bool { func (s *Service) finishReplan(request replanRequest) { defer s.replanWG.Done() + contextErr := request.ctx.Err() request.cancel() s.mu.Lock() if request.generation == s.latestReplanGeneration { s.activeReplanCancel = nil if s.publishedReplanGeneration != request.generation { + if s.lastPlanningError == "" { + if contextErr != nil { + s.lastPlanningError = "Planning stopped: " + contextErr.Error() + } else { + s.lastPlanningError = "Planning did not produce a usable plan" + } + } // Keep the last plan during a normal recalculation, but stop using // it when the replacement fails. A later retry must publish a new // plan before dispatch resumes; queuing work alone is not enough. @@ -1456,7 +1472,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "mode", request.params.Mode, "reason", request.reason, "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } now := s.planningNow() s.mu.RLock() @@ -1472,7 +1488,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { prices, err := s.Store.LoadPrices(s.Zone, sinceMs, untilMs) if err != nil { slog.Warn("mpc: load prices", "err", err) - return nil + return s.failedReplan(request, "Could not load electricity prices: "+err.Error()) } if request.wasCanceledByService() { return s.canceledReplan(request, "load-prices") @@ -1480,7 +1496,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { // The plan covers the published prices and nothing beyond them. if len(prices) == 0 { slog.Info("mpc: no prices available yet") - return nil + return s.failedReplan(request, "Waiting for electricity prices") } // Price rows may start up to 15 minutes before now, while weather rows @@ -1520,12 +1536,12 @@ func (s *Service) runReplan(request replanRequest) *Plan { } if len(resolved) != len(slots) { slog.Error("mpc: forecast changed horizon length; keeping previous plan") - return s.Latest() + return s.failedReplan(request, "The forecast changed the planning horizon") } for i := range slots { if resolved[i].StartMs != slots[i].StartMs || resolved[i].LenMin != slots[i].LenMin { slog.Error("mpc: forecast changed interval; keeping previous plan", "slot", i) - return s.Latest() + return s.failedReplan(request, fmt.Sprintf("The forecast changed planning slot %d", i)) } // Forecast selection cannot replace market data or physical limits. slots[i].PVW, slots[i].LoadW = resolved[i].PVW, resolved[i].LoadW @@ -1539,11 +1555,11 @@ func (s *Service) runReplan(request replanRequest) *Plan { // Qualified load models own their level. Unqualified historic daily // totals must not impose a floor on a changed or low-load household. if len(slots) == 0 { - return nil + return s.failedReplan(request, "No current electricity price slots are available") } if err := validateSlotChronology(slots); err != nil { slog.Error("mpc: invalid slot chronology; keeping previous plan", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } // Keep the point forecast and a downside-PV copy. The Core DP and the // Energyplan worker both plan against the downside copy; the point @@ -1595,13 +1611,13 @@ func (s *Service) runReplan(request replanRequest) *Plan { if len(fleet) > 0 { if err := validateBatteryFleetMembers(fleet); err != nil { slog.Error("mpc: invalid optimization parameters; keeping previous plan", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } var ok bool p, ok = s.onlineFleetParams(p, fleet, fuseMaxW) if !ok { slog.Warn("mpc: no online battery capacity with SoC — keeping previous plan") - return s.Latest() + return s.failedReplan(request, "Waiting for an online battery with capacity and charge level") } } else if p.CapacityWh > 0 { p.InitialSoC = currentSoC(s.Tele, p.InitialSoC) @@ -1692,12 +1708,18 @@ func (s *Service) runReplan(request replanRequest) *Plan { p.Loadpoints = []*LoadpointSpec{spec} } } - // Check the probe output before activeLoadpoints filters it. A plugged-in - // spec with broken physics must stop planning, not disappear and make the - // service silently solve a different battery-only problem. + } + warnings := limitPlanningLoadpointSteps(&p) + s.mu.Lock() + if request.generation == s.latestReplanGeneration { + s.planningWarnings = warnings + } + s.mu.Unlock() + if s.Loadpoints != nil || s.Loadpoint != nil { + // Invalid physics must not disappear into a battery-only problem. if err := validateLoadpointSpecs(planningLoadpointSpecs(p), make(map[string]string)); err != nil { slog.Error("mpc: invalid optimization parameters; keeping previous plan", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } active := p.activeLoadpoints() if len(active) > 0 { @@ -1710,15 +1732,15 @@ func (s *Service) runReplan(request replanRequest) *Plan { if err := validatePlanningSlots(slots); err != nil { slog.Error("mpc: invalid optimization inputs; keeping previous plan", "basis", "primary", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } if err := validatePlanningSlots(fallbackSlots); err != nil { slog.Error("mpc: invalid optimization inputs; keeping previous plan", "basis", "go-fallback", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } if err := validatePlanningParams(p); err != nil { slog.Error("mpc: invalid optimization parameters; keeping previous plan", "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } recoveryRequired := planningParamsRequireRecovery(p) if recoveryRequired && s.Optimizer == nil { @@ -1731,7 +1753,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "soc_start", p.InitialSoC, "soc_min", p.SoCMin, "soc_max", p.SoCMax) - return s.Latest() + return s.failedReplan(request, "Battery charge level cannot be represented within its limits") } if clamped { slog.Warn("mpc: planning from clamped SoC", @@ -1845,11 +1867,11 @@ func (s *Service) runReplan(request replanRequest) *Plan { "soc_start", p.InitialSoC, "soc_min", p.SoCMin, "soc_max", p.SoCMax) - return s.Latest() + return s.failedReplan(request, "Optimizer failed: "+err.Error()) } if modelErr := coreDPModelError(p); modelErr != nil { slog.Error("mpc: primary failed and fallback cannot represent this site; keeping previous plan", "err", err, "fallback", modelErr) - return s.Latest() + return s.failedReplan(request, "Optimizer failed: "+err.Error()+"; fallback unavailable: "+modelErr.Error()) } slog.Error("mpc: primary optimizer failed; using Core DP fallback", "err", err) slots = fallbackSlots @@ -1870,7 +1892,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "mode", p.Mode, "reason", request.reason, "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } if err := ValidatePlan(slots, p, &plan); err != nil { engine := "go-dp" @@ -1883,7 +1905,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { "reason", request.reason, "engine", engine, "err", err) - return s.Latest() + return s.failedReplan(request, err.Error()) } // Tag each action with the effective EMS mode so the UI can render @@ -1953,6 +1975,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { s.plannedPredictions = pp s.lastReason = request.reason s.publishedReplanGeneration = request.generation + s.lastPlanningError = "" reason := request.reason replanAtMs := s.lastReplanAt.UnixMilli() saveDiag := s.SaveDiag diff --git a/go/internal/units/charge_steps.go b/go/internal/units/charge_steps.go new file mode 100644 index 000000000..cbf6aefbe --- /dev/null +++ b/go/internal/units/charge_steps.go @@ -0,0 +1,22 @@ +package units + +import "math" + +// LimitChargeSteps owns its result. Invalid numbers stay visible to validation. +func LimitChargeSteps(steps []float64, maxW float64) ([]float64, bool) { + out := make([]float64, 0, len(steps)) + limited := false + seen := make(map[float64]bool, len(steps)) + for _, step := range steps { + if maxW > 0 && !math.IsNaN(maxW) && !math.IsInf(maxW, 0) && + !math.IsNaN(step) && !math.IsInf(step, 0) && step > maxW { + step = maxW + limited = true + } + if !seen[step] { + out = append(out, step) + seen[step] = true + } + } + return out, limited +} diff --git a/web/plan-brief.js b/web/plan-brief.js index 8894cf153..a00a1a966 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -236,6 +236,18 @@ function preparingBrief(status, hasBattery) { }; } +function failedBrief(status, hasBattery, error) { + return { + state: { key: 'blocked', label: 'Cannot plan', tone: 'warn' }, + next: { action: 'Planning could not finish', time: 'Fix the cause below, then try Replan' }, + reason: error, + constraint: 'No new schedule is being dispatched; FTW safety limits still apply', + forecast: { label: 'No accepted plan', detail: 'Planning stopped before a usable schedule was produced' }, + soc: batterySocNow(status, hasBattery, 'No planned charge path yet'), + planner: { label: 'Cannot plan', detail: error }, + }; +} + export function derivePlanBrief({ enabled = false, unavailableReason = "", @@ -243,6 +255,7 @@ export function derivePlanBrief({ status = {}, now = Date.now(), socOpts = {}, + planning = {}, } = {}) { fillPlanSoC(plan, socOpts); const actions = plan && Array.isArray(plan.actions) ? plan.actions : []; @@ -253,7 +266,13 @@ export function derivePlanBrief({ if (plannerMode) return blockedBrief(status, hasBattery, unavailableReason); return manualBrief(status, hasBattery); } - if (!actions.length) return preparingBrief(status, hasBattery); + if (planning.error && !planning.replanning) return failedBrief(status, hasBattery, planning.error); + const warnings = Array.isArray(planning.warnings) ? planning.warnings.join(' ') : ''; + if (!actions.length) { + const brief = preparingBrief(status, hasBattery); + if (warnings) brief.constraint = warnings; + return brief; + } const solver = plan.solver || {}; const stale = Boolean(status.plan_stale); @@ -297,6 +316,7 @@ export function derivePlanBrief({ } else if (stale) { constraint = "The schedule is old, so FTW is using safe live balancing"; } + if (warnings) constraint = warnings + ' ' + constraint; const finalAction = actions[actions.length - 1]; // The plan reaches as far as the published prices, and says how far. diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index 1b4169acb..c9754e7a5 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -15,6 +15,31 @@ const slot = (offsetMinutes, overrides = {}) => ({ }); describe("plan brief normalization", () => { + it("shows the actual planning failure before a first plan exists", () => { + const error = 'loadpoints[0].capacity_wh must be finite and greater than zero'; + const brief = derivePlanBrief({ enabled: true, planning: { error }, + status: { mode: 'planner_arbitrage', bat_soc: 0.84 }, now }); + assert.equal(brief.state.label, 'Cannot plan'); + assert.equal(brief.reason, error); + assert.equal(brief.planner.detail, error); + assert.doesNotMatch(brief.next.action, /Preparing/); + }); + + it("keeps a capped charger warning beside an active plan", () => { + const warning = 'Charger "Easee": charging steps above the 11000 W limit were reduced to 11000 W.'; + const brief = derivePlanBrief({ enabled: true, plan: { actions: [slot(-7)] }, + planning: { warnings: [warning] }, status: { mode: 'planner_arbitrage' }, now }); + assert.equal(brief.state.key, 'active'); + assert.match(brief.constraint, /Easee.*11000 W/); + assert.doesNotMatch(brief.constraint, /schedule is old/); + }); + + it("does not label a new pending attempt as a previous failure", () => { + const brief = derivePlanBrief({ enabled: true, planning: { error: 'old error', replanning: true }, now }); + assert.equal(brief.state.key, 'preparing'); + assert.doesNotMatch(brief.reason, /old error/); + }); + it("describes planner-off operation as manual and omits battery copy when absent", () => { const brief = derivePlanBrief({ enabled: false, diff --git a/web/plan.js b/web/plan.js index 9d1142474..b121c96e5 100644 --- a/web/plan.js +++ b/web/plan.js @@ -256,6 +256,7 @@ import { plan, status: state.status || {}, socOpts: state.socOpts, + planning: state.planMeta || {}, }); applyStateBadge('plan-state-badge', brief.state); setText('plan-next-action', brief.next.action); @@ -780,6 +781,8 @@ import { if (summary) { if (!state.enabled || !state.enabled.mpc) { summary.textContent = unavailablePlannerCopy(state.enabled && state.enabled.mpcReason).summary; + } else if (state.planMeta && state.planMeta.error && !state.planMeta.replanning) { + summary.textContent = 'Cannot plan · ' + state.planMeta.error; } else if (!plan) { const visibleInputs = []; if (state.prices && state.prices.length) visibleInputs.push('prices'); diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 37451fbb1..947f06cce 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -420,10 +420,19 @@ chargerSaveQueue = operation; return operation.then(function (result) { if (chargerSaveQueue !== operation) return; + if (result && Array.isArray(result.loadpoints)) { + ctx.config.loadpoints = result.loadpoints; + result.loadpoints.forEach(function (lp, idx) { + var input = document.querySelector('[data-path="loadpoints.' + idx + '.allowed_steps_w__str"]'); + if (input) input.value = fmtStepsW(lp.allowed_steps_w); + }); + } if (button) button.disabled = false; - feedback(result && result.restart_required + var message = result && result.restart_required ? 'Charger settings saved. FTW requires a restart to apply them.' - : 'Charger settings saved. No Save button needed.', 'success'); + : 'Charger settings saved. No Save button needed.'; + var warnings = result && Array.isArray(result.warnings) ? result.warnings : []; + feedback(message + (warnings.length ? ' ' + warnings.join(' ') : ''), warnings.length ? 'warning' : 'success'); }).catch(function (e) { if (chargerSaveQueue !== operation) return; if (button) button.disabled = false; diff --git a/web/settings/tabs/loadpoints.test.mjs b/web/settings/tabs/loadpoints.test.mjs index 20aaacb62..d1ce23aca 100644 --- a/web/settings/tabs/loadpoints.test.mjs +++ b/web/settings/tabs/loadpoints.test.mjs @@ -245,3 +245,60 @@ describe("first charger connection", () => { assert.match(render(config), /value="easee" selected/); }); }); + +describe("charger save corrections", () => { + it("shows the server warning and keeps the corrected steps for later saves", async () => { + const handlers = new Map(); + const input = { + dataset: { path: 'loadpoints.0.allowed_steps_w__str' }, + value: '0, 4800, 11100', + addEventListener(name, fn) { + handlers.set(name, [...(handlers.get(name) || []), fn]); + }, + }; + const status = { textContent: '', setAttribute() {} }; + const inline = { textContent: '' }; + const nodes = { 'settings-status': status, 'charger-save-status': inline }; + const config = { loadpoints: [{ id: 'Easee', max_charge_w: 11000, allowed_steps_w: [0, 4800, 11000] }] }; + const corrected = [{ ...config.loadpoints[0], allowed_steps_w: [0, 4800, 11000] }]; + const warning = 'Charger "Easee": charging steps above the 11000 W limit were reduced to 11000 W.'; + const previousDocument = globalThis.document; + const previousFetch = globalThis.fetch; + const saves = []; + const response = body => ({ ok: true, json: async () => body }); + try { + globalThis.document = { + getElementById: id => nodes[id] || null, + querySelector: () => input, + }; + globalThis.fetch = async () => ({ ok: false }); + window.FTWSettings.catalogByLua = {}; + window.FTWSettings.tabs.loadpoints.after({ + config, + bodyEl: { querySelectorAll: selector => selector.includes('data-path') ? [input] : [] }, + captureCurrentTab() {}, + apiFetch: async (path, opts) => { + if (opts && opts.method === 'POST') { + saves.push(JSON.parse(opts.body)); + return response({ warnings: [warning], loadpoints: corrected }); + } + return response({ loadpoints: [] }); + }, + }); + for (const fn of handlers.get('change')) fn(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(saves[0].loadpoints[0].allowed_steps_w, [0, 4800, 11100]); + assert.deepEqual(config.loadpoints[0].allowed_steps_w, [0, 4800, 11000]); + assert.equal(input.value, '0, 4800, 11000'); + assert.equal(status.className, 'settings-status warning'); + assert.ok(status.textContent.includes(warning)); + assert.ok(inline.textContent.includes(warning)); + for (const fn of handlers.get('change')) fn(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(saves[1].loadpoints[0].allowed_steps_w, [0, 4800, 11000]); + } finally { + globalThis.document = previousDocument; + globalThis.fetch = previousFetch; + } + }); +});