diff --git a/.changeset/forecast-trust-export.md b/.changeset/forecast-trust-export.md new file mode 100644 index 000000000..8333c1972 --- /dev/null +++ b/.changeset/forecast-trust-export.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The planner now has a household preference object on the Plan card: follow-the-forecast (cautious / balanced / bold) and a battery-export permission (unknown / not allowed / allowed). Balanced is today's default. Unknown export does not sell from the battery. Sites that were on Active arbitrage must confirm before selling again. Settings keep house reserve on top and bury engine knobs; weather no longer asks for array orientation on the normal path. diff --git a/config.example.yaml b/config.example.yaml index cb6ec1c14..7753dff96 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -235,6 +235,8 @@ fleet_ping: # enabled: true # engine: python # mode: passive_arbitrage +# forecast_trust: balanced # cautious | balanced | bold (first boot; live value is SQLite) +# battery_export: unknown # unknown | not_allowed | allowed (unknown = no battery sale) # horizon_hours: 48 # interval_min: 15 # soc_min: 0.10 diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index 1a573442a..73cef2f2a 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -191,6 +191,7 @@ type appModes struct { ctrlMu *sync.Mutex state *state.Store mpc *mpc.Service + prefs *config.PlannerPrefs } func (a *appModes) SetMode(ctx context.Context, m control.Mode) error { @@ -206,6 +207,13 @@ func (a *appModes) SetMode(ctx context.Context, m control.Mode) error { slog.Warn("app uplink could not persist the mode", "err", err) } } + if a.prefs != nil { + var save func(string, string) error + if a.state != nil { + save = a.state.SaveConfig + } + a.prefs.ApplyExportFromMode(string(m), save) + } if mm, ok := control.PlannerMPCMode(m); ok && a.mpc != nil { // Forced replan, off this goroutine. mpc.SetMode replans before it // returns, and the Python optimizer can take longer than the app @@ -464,6 +472,7 @@ func startAppLink( priceSvc *prices.Service, ctrl *control.State, ctrlMu *sync.Mutex, + prefs *config.PlannerPrefs, revision *control.Revision, siteMeterStale time.Duration, gateway *lateAPI, @@ -489,7 +498,7 @@ func startAppLink( started: processStarted, siteMeterStale: siteMeterStale, } info := appBoxInfo{id: boxID, build: build, tz: tz} - modes := &appModes{ctrl: ctrl, ctrlMu: ctrlMu, state: st, mpc: planner} + modes := &appModes{ctrl: ctrl, ctrlMu: ctrlMu, state: st, mpc: planner, prefs: prefs} plans := &appPlans{planner: planner, ctrl: ctrl, ctrlMu: ctrlMu} // History rides on the energy ledger, so it exists exactly when state diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 92bf2ca7c..86ebc45c5 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -533,6 +533,29 @@ func main() { ctrl.Mode = m } } + storedTrust, _ := st.LoadConfig(config.StateKeyForecastTrust) + storedExport, _ := st.LoadConfig(config.StateKeyBatteryExport) + yamlTrust, yamlExport := "", "" + if cfg.Planner != nil { + yamlTrust = cfg.Planner.ForecastTrust + yamlExport = cfg.Planner.BatteryExport + } + trust, export, missingPrefs := config.ResolvePlannerPrefs(storedTrust, storedExport, string(ctrl.Mode), yamlTrust, yamlExport) + plannerPrefs := config.NewPlannerPrefs(trust, export) + if missingPrefs { + if err := st.SaveConfig(config.StateKeyForecastTrust, string(trust)); err != nil { + slog.Warn("failed to persist forecast_trust", "err", err) + } + if err := st.SaveConfig(config.StateKeyBatteryExport, string(export)); err != nil { + slog.Warn("failed to persist battery_export", "err", err) + } + } + if ctrl.Mode == control.ModePlannerArbitrage && export != config.BatteryExportAllowed { + ctrl.Mode = control.ModePlannerPassiveArbitrage + if err := st.SaveConfig("mode", string(ctrl.Mode)); err != nil { + slog.Warn("failed to persist mode after export migration", "err", err) + } + } if v, ok := st.LoadConfig("grid_target_w"); ok { if f, err := strconv.ParseFloat(v, 64); err == nil { ctrl.SetGridTarget(f) @@ -1031,7 +1054,7 @@ func main() { deps.HA = nil slog.Info("HA bridge stopped (disabled in config)") case haBridge == nil && haEnabled: - if bridge, err := ha.Start(newCfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)); err != nil { + if bridge, err := ha.Start(newCfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc, plannerPrefs), mpcPlanSource(mpcSvc), haEnergySource(st)); err != nil { slog.Warn("HA bridge start failed", "err", err) } else { haBridge = bridge @@ -1287,7 +1310,7 @@ func main() { } // Downside-PV safety planning (forecast − k·σ) — replaces the old SoC // safety floor. Unset config → default 1.0; explicit 0 → raw forecast. - mpcSvc.PVForecastSafetyK = cfg.Planner.PVSafetyK() + mpcSvc.PVForecastSafetyK = cfg.Planner.EffectiveSafetyK(trust) if cfg.Planner != nil { mpcSvc.MinArbitrageSpreadOreKwh = cfg.Planner.MinArbitrageSpreadOreKwh } @@ -2251,7 +2274,7 @@ func main() { appAPI := &lateAPI{} appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink( ctx, cfg, identityKeyPath, boxID, Version, - st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, + st, tel, mpcSvc, lpMgr, lpController, priceSvc, ctrl, ctrlMu, plannerPrefs, controlRev, appLinkWatchdog, appAPI, webPush, ) switch { @@ -2322,6 +2345,7 @@ func main() { Prices: priceSvc, Forecast: forecastSvc, MPC: mpcSvc, + PlannerPrefs: plannerPrefs, PVModel: pvSvc, LoadModel: loadSvc, Loadpoints: lpMgr, @@ -2497,7 +2521,7 @@ func main() { // ---- HA MQTT bridge (optional) ---- if cfg.HomeAssistant != nil && cfg.HomeAssistant.Enabled { - bridge, err := ha.Start(cfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)) + bridge, err := ha.Start(cfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc, plannerPrefs), mpcPlanSource(mpcSvc), haEnergySource(st)) if err != nil { slog.Warn("HA MQTT bridge failed to start", "err", err) } else { @@ -4116,7 +4140,7 @@ func restoreLatestMPCDiagnostic(st *state.Store, svc *mpc.Service, now time.Time // path can share the exact same wiring — drift between them would mean // HA commands behave one way after boot and a different way after a // hot-reload, which is the kind of silent skew that's hardest to debug. -func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, st *state.Store, mpcSvc *mpc.Service) ha.CommandCallbacks { +func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, st *state.Store, mpcSvc *mpc.Service, prefs *config.PlannerPrefs) ha.CommandCallbacks { return ha.CommandCallbacks{ SetMode: func(m string) error { mode := control.Mode(m) @@ -4139,6 +4163,9 @@ func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, s if err := st.SaveConfig("mode", m); err != nil { return err } + if prefs != nil { + prefs.ApplyExportFromMode(m, st.SaveConfig) + } if mm, ok := control.PlannerMPCMode(mode); ok && mpcSvc != nil { mpcSvc.SetMode(ctx, mm) } diff --git a/go/internal/api/api.go b/go/internal/api/api.go index ce3011678..47f27d7bd 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -141,6 +141,10 @@ type Deps struct { // Optional: MPC planner. Nil if disabled or a buildMPC gate skipped it. MPC *mpc.Service + // PlannerPrefs is the live household planner object (forecast trust + + // battery export). Nil treats GET as balanced + unknown. + PlannerPrefs *config.PlannerPrefs + // Optional: PV digital-twin self-learner. PVModel *pvmodel.Service @@ -399,6 +403,8 @@ func (s *Server) routes() { s.handle("PATCH /api/app-link/devices/{id}", Configure, s.handleAppLinkDeviceRole) s.handle("GET /api/fleet-ping", Read, s.handleFleetPing) s.handle("POST /api/mode", Actuate, s.handleSetMode, Via(appproto.OpSetMode)) + s.handle("GET /api/planner/prefs", Read, s.handleGetPlannerPrefs) + s.handle("POST /api/planner/prefs", Actuate, s.handleSetPlannerPrefs) s.handle("GET /api/modes", Read, s.handleModes) s.handle("POST /api/target", Actuate, s.handleSetTarget) s.handle("POST /api/peak_limit", Actuate, s.handleSetPeakLimit) @@ -1136,9 +1142,16 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { } v2xPolicy := s.v2xPolicyStatus(v2xGridW) + trust, export, yamlCustom, mappedK, mappedMode := s.plannerPrefsSnapshot() + resp := map[string]any{ "version": s.deps.Version, "mode": ctrl.Mode, + "forecast_trust": trust, + "battery_export": export, + "planner_yaml_custom": yamlCustom, + "planner_mapped_k": mappedK, + "planner_mapped_mode": mappedMode, "troubleshooting_mode": troubleshootingMode, "plan_stale": ctrl.PlanStale, "grid_w": gridW, @@ -1662,6 +1675,9 @@ func (s *Server) handleSetMode(w http.ResponseWriter, r *http.Request) { if err := s.deps.State.SaveConfig("mode", req.Mode); err != nil { slog.Warn("failed to persist mode", "err", err) } + if s.deps.PlannerPrefs != nil && s.deps.State != nil { + s.deps.PlannerPrefs.ApplyExportFromMode(req.Mode, s.deps.State.SaveConfig) + } // Propagate to MPC if switching to a planner mode and force an // immediate replan. control.PlannerMPCMode is the single source of the // ModePlanner* → mpc.Mode mapping; ok is false for non-planner modes (and diff --git a/go/internal/api/api_passthrough_test.go b/go/internal/api/api_passthrough_test.go index fcb673dfb..f406ddfea 100644 --- a/go/internal/api/api_passthrough_test.go +++ b/go/internal/api/api_passthrough_test.go @@ -602,6 +602,8 @@ func TestRouteTierIgnoresTheMethod(t *testing.T) { {"POST", "/api/self_tune/start", apiauth.TierActuate, "it drives every battery through a step pattern"}, {"POST", "/api/notifications/test", apiauth.TierConfigure, "a late test message is the same message"}, {"POST", "/api/mode", apiauth.TierActuate, ""}, + {"GET", "/api/planner/prefs", apiauth.TierRead, "household planner prefs are status"}, + {"POST", "/api/planner/prefs", apiauth.TierActuate, "prefs change dispatch"}, {"DELETE", "/api/battery/manual_hold", apiauth.TierActuate, ""}, // Sibling routes priced apart on purpose: a charging schedule is a diff --git a/go/internal/api/api_planner_prefs.go b/go/internal/api/api_planner_prefs.go new file mode 100644 index 000000000..e3fc84ca1 --- /dev/null +++ b/go/internal/api/api_planner_prefs.go @@ -0,0 +1,114 @@ +package api + +import ( + "context" + "net/http" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" +) + +func (s *Server) plannerPrefsSnapshot() (trust config.ForecastTrust, export config.BatteryExport, yamlCustom bool, mappedK float64, mappedMode string) { + trust, export = s.deps.PlannerPrefs.Get() + var planner *config.Planner + if s.deps.Cfg != nil { + s.deps.CfgMu.RLock() + planner = s.deps.Cfg.Planner + s.deps.CfgMu.RUnlock() + } + yamlCustom = planner.YAMLCustomK() + mappedK = planner.EffectiveSafetyK(trust) + mappedMode = export.PlannerModeKey() + return +} + +func (s *Server) handleGetPlannerPrefs(w http.ResponseWriter, r *http.Request) { + trust, export, yamlCustom, mappedK, mappedMode := s.plannerPrefsSnapshot() + writeJSON(w, 200, map[string]any{ + "forecast_trust": trust, + "battery_export": export, + "yaml_custom": yamlCustom, + "mapped_k": mappedK, + "mapped_mode": mappedMode, + }) +} + +func (s *Server) handleSetPlannerPrefs(w http.ResponseWriter, r *http.Request) { + var req struct { + ForecastTrust string `json:"forecast_trust"` + BatteryExport string `json:"battery_export"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + trust, ok := config.ParseForecastTrust(req.ForecastTrust) + if !ok || req.ForecastTrust == "" { + writeJSON(w, 400, map[string]string{"error": "forecast_trust must be cautious, balanced, or bold"}) + return + } + export, ok := config.ParseBatteryExport(req.BatteryExport) + if !ok { + writeJSON(w, 400, map[string]string{"error": "battery_export must be unknown, not_allowed, or allowed"}) + return + } + if err := s.applyPlannerPrefs(r.Context(), trust, export); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + _, _, yamlCustom, mappedK, mappedMode := s.plannerPrefsSnapshot() + writeJSON(w, 200, map[string]any{ + "status": "ok", + "forecast_trust": trust, + "battery_export": export, + "yaml_custom": yamlCustom, + "mapped_k": mappedK, + "mapped_mode": mappedMode, + }) +} + +func (s *Server) applyPlannerPrefs(ctx context.Context, trust config.ForecastTrust, export config.BatteryExport) error { + if s.deps.PlannerPrefs == nil { + s.deps.PlannerPrefs = config.NewPlannerPrefs(trust, export) + } else { + s.deps.PlannerPrefs.Set(trust, export) + } + if s.deps.State != nil { + if err := s.deps.State.SaveConfig(config.StateKeyForecastTrust, string(trust)); err != nil { + return err + } + if err := s.deps.State.SaveConfig(config.StateKeyBatteryExport, string(export)); err != nil { + return err + } + } + mapped := control.Mode(export.PlannerModeKey()) + if s.deps.Ctrl != nil && s.deps.CtrlMu != nil { + s.deps.CtrlMu.Lock() + inPlanner := s.deps.Ctrl.Mode.IsPlannerMode() + s.deps.CtrlMu.Unlock() + if inPlanner { + s.deps.CtrlMu.Lock() + err := s.deps.Ctrl.ApplyMode(mapped) + s.deps.CtrlMu.Unlock() + if err != nil { + return err + } + if s.deps.State != nil { + _ = s.deps.State.SaveConfig("mode", string(mapped)) + } + if mm, ok := control.PlannerMPCMode(mapped); ok && s.deps.MPC != nil { + s.deps.MPC.SetMode(ctx, mm) + } + } + } + if s.deps.MPC != nil { + var planner *config.Planner + if s.deps.Cfg != nil { + s.deps.CfgMu.RLock() + planner = s.deps.Cfg.Planner + s.deps.CfgMu.RUnlock() + } + s.deps.MPC.SetSafetyK(ctx, planner.EffectiveSafetyK(trust)) + } + return nil +} diff --git a/go/internal/api/api_planner_prefs_test.go b/go/internal/api/api_planner_prefs_test.go new file mode 100644 index 000000000..7cad6c2d8 --- /dev/null +++ b/go/internal/api/api_planner_prefs_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/state" +) + +func plannerPrefsServer(t *testing.T, mode control.Mode) (*Server, *control.State, *state.Store) { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + ctrl := control.NewState(0, 50, "meter") + ctrl.Mode = mode + prefs := config.NewPlannerPrefs(config.ForecastTrustBalanced, config.BatteryExportUnknown) + srv := New(&Deps{ + Ctrl: ctrl, + CtrlMu: &sync.Mutex{}, + State: st, + CfgMu: &sync.RWMutex{}, + Cfg: &config.Config{}, + PlannerPrefs: prefs, + }) + return srv, ctrl, st +} + +func TestGetPlannerPrefsDefaults(t *testing.T) { + srv, _, _ := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + req := httptest.NewRequest(http.MethodGet, "/api/planner/prefs", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["forecast_trust"] != "balanced" { + t.Errorf("trust=%v", got["forecast_trust"]) + } + if got["battery_export"] != "unknown" { + t.Errorf("export=%v", got["battery_export"]) + } + if got["mapped_mode"] != "planner_passive_arbitrage" { + t.Errorf("mapped_mode=%v", got["mapped_mode"]) + } + if got["mapped_k"] != 1.0 { + t.Errorf("mapped_k=%v, want 1", got["mapped_k"]) + } +} + +func TestPostPlannerPrefsUnknownNeverArbitrage(t *testing.T) { + srv, ctrl, st := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + req := httptest.NewRequest(http.MethodPost, "/api/planner/prefs", + strings.NewReader(`{"forecast_trust":"bold","battery_export":"unknown"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if ctrl.Mode != control.ModePlannerPassiveArbitrage { + t.Errorf("mode=%s, want passive (unknown must not export)", ctrl.Mode) + } + if v, _ := st.LoadConfig(config.StateKeyForecastTrust); v != "bold" { + t.Errorf("stored trust=%q", v) + } + if v, _ := st.LoadConfig(config.StateKeyBatteryExport); v != "unknown" { + t.Errorf("stored export=%q", v) + } +} + +func TestPostPlannerPrefsAllowedMapsToArbitrage(t *testing.T) { + srv, ctrl, _ := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + req := httptest.NewRequest(http.MethodPost, "/api/planner/prefs", + strings.NewReader(`{"forecast_trust":"cautious","battery_export":"allowed"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if ctrl.Mode != control.ModePlannerArbitrage { + t.Errorf("mode=%s, want planner_arbitrage", ctrl.Mode) + } +} + +func TestPostPlannerPrefsRejectsJunk(t *testing.T) { + srv, _, _ := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + req := httptest.NewRequest(http.MethodPost, "/api/planner/prefs", + strings.NewReader(`{"forecast_trust":"spicy","battery_export":"unknown"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400", rr.Code) + } +} + +func TestSetModeActiveConfirmsExport(t *testing.T) { + srv, _, st := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + req := httptest.NewRequest(http.MethodPost, "/api/mode", + strings.NewReader(`{"mode":"planner_arbitrage"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if v, _ := st.LoadConfig(config.StateKeyBatteryExport); v != "allowed" { + t.Errorf("export=%q, want allowed", v) + } +} + +func TestYAMLCustomKWinsOverTrust(t *testing.T) { + k := 0.25 + srv, _, _ := plannerPrefsServer(t, control.ModePlannerPassiveArbitrage) + srv.deps.Cfg.Planner = &config.Planner{PVForecastSafetyK: &k} + req := httptest.NewRequest(http.MethodGet, "/api/planner/prefs", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["yaml_custom"] != true { + t.Errorf("yaml_custom=%v", got["yaml_custom"]) + } + if got["mapped_k"] != 0.25 { + t.Errorf("mapped_k=%v, want 0.25", got["mapped_k"]) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 6ed18af55..18971be7c 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -579,6 +579,12 @@ type OptimizerMultistage struct { type Planner struct { Enabled bool `yaml:"enabled" json:"enabled"` Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` + // ForecastTrust is the first-boot household slider: cautious | balanced | bold. + // After first boot the live value lives in SQLite (forecast_trust), like mode. + ForecastTrust string `yaml:"forecast_trust,omitempty" json:"forecast_trust,omitempty"` + // BatteryExport is the first-boot battery-sale permission: + // unknown | not_allowed | allowed. Live value is SQLite battery_export. + BatteryExport string `yaml:"battery_export,omitempty" json:"battery_export,omitempty"` // Engine selects the primary optimizer: "python" (default) runs the // CVXPY/HiGHS worker; "dp" is the legacy in-process rollback engine. Engine string `yaml:"engine,omitempty" json:"engine,omitempty"` @@ -1911,6 +1917,16 @@ func (c *Config) Validate() error { } if c.Planner != nil { p := c.Planner + if p.ForecastTrust != "" { + if _, ok := ParseForecastTrust(p.ForecastTrust); !ok { + return fmt.Errorf("planner.forecast_trust must be cautious, balanced, or bold, got %q", p.ForecastTrust) + } + } + if p.BatteryExport != "" { + if _, ok := ParseBatteryExport(p.BatteryExport); !ok { + return fmt.Errorf("planner.battery_export must be unknown, not_allowed, or allowed, got %q", p.BatteryExport) + } + } switch p.Engine { case "", "python", "dp": default: diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index d3b6c1522..05680897d 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -94,6 +94,37 @@ planner: } } +func TestPlannerForecastTrustAndExportValidate(t *testing.T) { + base := ` +site: + name: Test +fuse: + max_amps: 16 +drivers: + - name: ferroamp + lua: drivers/ferroamp.lua + is_site_meter: true + capabilities: + mqtt: + host: 192.168.1.153 +planner: + mode: passive_arbitrage +` + if _, err := Parse([]byte(base+" forecast_trust: spicy\n"), "/tmp"); err == nil { + t.Fatal("expected error for junk forecast_trust") + } + if _, err := Parse([]byte(base+" battery_export: maybe\n"), "/tmp"); err == nil { + t.Fatal("expected error for junk battery_export") + } + c, err := Parse([]byte(base+" forecast_trust: cautious\n battery_export: not_allowed\n"), "/tmp") + if err != nil { + t.Fatal(err) + } + if c.Planner.ForecastTrust != "cautious" || c.Planner.BatteryExport != "not_allowed" { + t.Fatalf("got trust=%q export=%q", c.Planner.ForecastTrust, c.Planner.BatteryExport) + } +} + func TestLoadMinimalYAML(t *testing.T) { c, err := Parse([]byte(minimalYAML), "/tmp") if err != nil { diff --git a/go/internal/config/planner_prefs.go b/go/internal/config/planner_prefs.go new file mode 100644 index 000000000..4385d9fbc --- /dev/null +++ b/go/internal/config/planner_prefs.go @@ -0,0 +1,178 @@ +package config + +import "sync" + +const ( + StateKeyForecastTrust = "forecast_trust" + StateKeyBatteryExport = "battery_export" +) + +// ForecastTrust is how hard the planner bets the PV/price forecast is right. +// cautious holds reserve (high k). bold follows the raw forecast (k=0). +type ForecastTrust string + +const ( + ForecastTrustCautious ForecastTrust = "cautious" + ForecastTrustBalanced ForecastTrust = "balanced" + ForecastTrustBold ForecastTrust = "bold" +) + +// BatteryExport is the household permission for battery-driven grid export. +// unknown means not checked: treat as not allowed. +type BatteryExport string + +const ( + BatteryExportUnknown BatteryExport = "unknown" + BatteryExportNotAllowed BatteryExport = "not_allowed" + BatteryExportAllowed BatteryExport = "allowed" +) + +func ParseForecastTrust(s string) (ForecastTrust, bool) { + switch ForecastTrust(s) { + case ForecastTrustCautious, ForecastTrustBalanced, ForecastTrustBold: + return ForecastTrust(s), true + case "": + return ForecastTrustBalanced, true + default: + return "", false + } +} + +func ParseBatteryExport(s string) (BatteryExport, bool) { + switch BatteryExport(s) { + case BatteryExportUnknown, BatteryExportNotAllowed, BatteryExportAllowed: + return BatteryExport(s), true + default: + return "", false + } +} + +// SafetyK is the PV downside haircut scale for this trust level. +func (t ForecastTrust) SafetyK() float64 { + switch t { + case ForecastTrustCautious: + return 2.0 + case ForecastTrustBold: + return 0.0 + default: + return 1.0 + } +} + +// PlannerModeKey is the control/MPC planner mode that matches this permission. +// unknown and not_allowed both stay on passive (no battery export). +func (e BatteryExport) PlannerModeKey() string { + if e == BatteryExportAllowed { + return "planner_arbitrage" + } + return "planner_passive_arbitrage" +} + +// DeriveBatteryExport maps a persisted control mode onto an export permission +// when SQLite has never stored one. Active arbitrage becomes unknown so the +// household must confirm selling; it does not keep selling in silence. +// ExportFromPlannerMode updates the permission when the operator picks a +// planner mode (HA, app, /api/mode). Manual modes return ok=false. +func ExportFromPlannerMode(mode string) (BatteryExport, bool) { + switch mode { + case "planner_arbitrage": + return BatteryExportAllowed, true + case "planner_passive_arbitrage": + return BatteryExportNotAllowed, true + default: + return "", false + } +} + +func DeriveBatteryExport(persistedMode string) BatteryExport { + switch persistedMode { + case "planner_arbitrage": + return BatteryExportUnknown + case "planner_passive_arbitrage", "planner_self", "planner_cheap": + return BatteryExportNotAllowed + default: + return BatteryExportUnknown + } +} + +// ResolvePlannerPrefs builds the live household object from SQLite, then YAML, +// then the persisted control mode. missingStored is true when either SQLite +// key was absent so the caller should persist the result. +func ResolvePlannerPrefs(storedTrust, storedExport, persistedMode, yamlTrust, yamlExport string) (trust ForecastTrust, export BatteryExport, missingStored bool) { + if t, ok := ParseForecastTrust(storedTrust); ok && storedTrust != "" { + trust = t + } else if t, ok := ParseForecastTrust(yamlTrust); ok { + trust = t + if storedTrust == "" { + missingStored = true + } + } else { + trust = ForecastTrustBalanced + missingStored = true + } + if e, ok := ParseBatteryExport(storedExport); ok { + export = e + } else if e, ok := ParseBatteryExport(yamlExport); ok && yamlExport != "" { + export = e + missingStored = true + } else { + export = DeriveBatteryExport(persistedMode) + missingStored = true + } + return trust, export, missingStored +} + +// EffectiveSafetyK prefers an explicit YAML k over the trust mapping. +func (p *Planner) EffectiveSafetyK(trust ForecastTrust) float64 { + if p != nil && p.PVForecastSafetyK != nil { + return *p.PVForecastSafetyK + } + return trust.SafetyK() +} + +func (p *Planner) YAMLCustomK() bool { + return p != nil && p.PVForecastSafetyK != nil +} + +// PlannerPrefs is the in-memory household planner object. SQLite is the +// durable copy; this is what /api/status reads on every poll. +type PlannerPrefs struct { + mu sync.Mutex + Trust ForecastTrust + Export BatteryExport +} + +func NewPlannerPrefs(trust ForecastTrust, export BatteryExport) *PlannerPrefs { + return &PlannerPrefs{Trust: trust, Export: export} +} + +func (p *PlannerPrefs) Get() (ForecastTrust, BatteryExport) { + if p == nil { + return ForecastTrustBalanced, BatteryExportUnknown + } + p.mu.Lock() + defer p.mu.Unlock() + return p.Trust, p.Export +} + +func (p *PlannerPrefs) Set(trust ForecastTrust, export BatteryExport) { + if p == nil { + return + } + p.mu.Lock() + p.Trust = trust + p.Export = export + p.mu.Unlock() +} + +func (p *PlannerPrefs) ApplyExportFromMode(mode string, save func(key, value string) error) { + export, ok := ExportFromPlannerMode(mode) + if !ok || p == nil { + return + } + trust, _ := p.Get() + p.Set(trust, export) + if save != nil { + _ = save(StateKeyBatteryExport, string(export)) + } +} diff --git a/go/internal/config/planner_prefs_test.go b/go/internal/config/planner_prefs_test.go new file mode 100644 index 000000000..05c8ce9eb --- /dev/null +++ b/go/internal/config/planner_prefs_test.go @@ -0,0 +1,99 @@ +package config + +import "testing" + +func TestForecastTrustSafetyK(t *testing.T) { + if got := ForecastTrustCautious.SafetyK(); got != 2 { + t.Errorf("cautious k=%v, want 2", got) + } + if got := ForecastTrustBalanced.SafetyK(); got != 1 { + t.Errorf("balanced k=%v, want 1", got) + } + if got := ForecastTrustBold.SafetyK(); got != 0 { + t.Errorf("bold k=%v, want 0", got) + } + if got := ForecastTrust("").SafetyK(); got != 1 { + t.Errorf("empty k=%v, want 1", got) + } +} + +func TestBatteryExportPlannerModeKey(t *testing.T) { + if got := BatteryExportAllowed.PlannerModeKey(); got != "planner_arbitrage" { + t.Errorf("allowed → %s, want planner_arbitrage", got) + } + for _, e := range []BatteryExport{BatteryExportUnknown, BatteryExportNotAllowed, ""} { + if got := e.PlannerModeKey(); got != "planner_passive_arbitrage" { + t.Errorf("%q → %s, want planner_passive_arbitrage", e, got) + } + } +} + +func TestDeriveBatteryExport(t *testing.T) { + cases := []struct { + mode string + want BatteryExport + }{ + {"planner_arbitrage", BatteryExportUnknown}, + {"planner_passive_arbitrage", BatteryExportNotAllowed}, + {"planner_self", BatteryExportNotAllowed}, + {"planner_cheap", BatteryExportNotAllowed}, + {"idle", BatteryExportUnknown}, + {"", BatteryExportUnknown}, + } + for _, tc := range cases { + if got := DeriveBatteryExport(tc.mode); got != tc.want { + t.Errorf("mode %q → %q, want %q", tc.mode, got, tc.want) + } + } +} + +func TestResolvePlannerPrefsStoredWins(t *testing.T) { + trust, export, missing := ResolvePlannerPrefs("bold", "allowed", "planner_passive_arbitrage", "cautious", "not_allowed") + if trust != ForecastTrustBold || export != BatteryExportAllowed || missing { + t.Fatalf("got trust=%s export=%s missing=%v", trust, export, missing) + } +} + +func TestResolvePlannerPrefsActiveUpgradeAsks(t *testing.T) { + trust, export, missing := ResolvePlannerPrefs("", "", "planner_arbitrage", "", "") + if trust != ForecastTrustBalanced { + t.Errorf("trust=%s, want balanced", trust) + } + if export != BatteryExportUnknown { + t.Errorf("export=%s, want unknown (must confirm)", export) + } + if !missing { + t.Error("empty sqlite should be missingStored") + } +} + +func TestResolvePlannerPrefsPassiveStaysOff(t *testing.T) { + _, export, _ := ResolvePlannerPrefs("", "", "planner_passive_arbitrage", "", "") + if export != BatteryExportNotAllowed { + t.Errorf("export=%s, want not_allowed", export) + } +} + +func TestPlannerEffectiveSafetyKYAMLWins(t *testing.T) { + k := 0.5 + p := &Planner{PVForecastSafetyK: &k} + if got := p.EffectiveSafetyK(ForecastTrustCautious); got != 0.5 { + t.Errorf("yaml k=%v, want 0.5", got) + } + if !p.YAMLCustomK() { + t.Error("YAMLCustomK should be true") + } + empty := &Planner{} + if got := empty.EffectiveSafetyK(ForecastTrustCautious); got != 2 { + t.Errorf("mapped k=%v, want 2", got) + } +} + +func TestParseForecastTrustRejectsJunk(t *testing.T) { + if _, ok := ParseForecastTrust("spicy"); ok { + t.Fatal("spicy must not parse") + } + if _, ok := ParseBatteryExport("maybe"); ok { + t.Fatal("maybe must not parse") + } +} diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 90edce248..5189cfc8a 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -650,6 +650,18 @@ func (s *Service) SetMode(ctx context.Context, mode Mode) { s.runReplan(request) } +// SetSafetyK updates the downside-PV haircut scale and replans. +func (s *Service) SetSafetyK(ctx context.Context, k float64) { + if s == nil { + return + } + s.mu.Lock() + s.PVForecastSafetyK = k + request := s.beginReplanLocked(ctx, "safety_k_changed") + s.mu.Unlock() + s.runReplan(request) +} + // Start runs the planner in a goroutine. Does an initial plan immediately. func (s *Service) Start(ctx context.Context) { if s == nil { diff --git a/web/app.css b/web/app.css index a32b34162..3ce81ad2e 100644 --- a/web/app.css +++ b/web/app.css @@ -1025,6 +1025,126 @@ body.ftw-app .strategy-hint { color: var(--fg); line-height: 1.5; } +body.ftw-app .forecast-trust { + margin: 0 0 12px; +} +body.ftw-app .forecast-trust input[type="range"] { + width: 100%; + accent-color: var(--accent-e); + margin: 0; +} +body.ftw-app .forecast-trust input[type="range"]:disabled { + opacity: 0.45; + cursor: not-allowed; +} +body.ftw-app .forecast-trust-labels { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 4px; + color: var(--fg-dim); + font-family: var(--sans); + font-size: 12px; +} +body.ftw-app .forecast-trust-hedge { + margin-top: 8px; + color: var(--fg-dim); + font-family: var(--mono); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +body.ftw-app .forecast-trust-help, +body.ftw-app .forecast-trust-yaml, +body.ftw-app .plan-export-help, +body.ftw-app .plan-export-unknown { + margin: 8px 0 0; + color: var(--fg-dim); + font-family: var(--sans); + font-size: 12px; + line-height: 1.45; +} +body.ftw-app .plan-export { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); +} +body.ftw-app .plan-export-banner { + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--ink-sunken); +} +body.ftw-app .plan-export-banner p { + margin: 0 0 8px; + color: var(--fg); + font-family: var(--sans); + font-size: 13px; + line-height: 1.4; +} +body.ftw-app .plan-export-banner-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +body.ftw-app #plan-export-allow { + background: var(--accent-e); + border: 1px solid var(--accent-e); + border-radius: 6px; + color: var(--on-accent); + cursor: pointer; + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + padding: 6px 12px; +} +body.ftw-app .plan-export-row { + display: flex; + gap: 8px; + align-items: flex-start; + color: var(--fg); + font-family: var(--sans); + font-size: 13px; + line-height: 1.4; +} +body.ftw-app .plan-export-row input { + margin-top: 3px; + accent-color: var(--accent-e); +} +body.ftw-app .plan-export-sentence { + margin: 10px 0 0; + color: var(--fg); + font-family: var(--sans); + font-size: 13px; + line-height: 1.4; +} +body.ftw-app .engine-details { + margin-top: 16px; + border-top: 1px solid var(--line); + padding-top: 10px; +} +body.ftw-app .engine-details > summary { + cursor: pointer; + color: var(--fg-dim); + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.02em; + list-style: none; +} +body.ftw-app .engine-details > summary::-webkit-details-marker { + display: none; +} +body.ftw-app .engine-details > summary::before { + content: "› "; + color: var(--fg-muted); +} +body.ftw-app .engine-details[open] > summary::before { + content: "▾ "; +} +body.ftw-app .engine-details > fieldset { + margin-top: 10px; +} body.ftw-app .slider-group-compact { gap: 10px; } @@ -2503,6 +2623,15 @@ body.ftw-app .diagnose-detail { } body.ftw-app .plan-actions { align-items: stretch; flex-wrap: wrap; } body.ftw-app .more-actions { grid-template-columns: 1fr; } + body.ftw-app .forecast-trust-help, + body.ftw-app .plan-export-help, + body.ftw-app .plan-export-sentence { + font-size: 12px; + } + body.ftw-app .plan-export-banner-actions { + flex-direction: column; + align-items: stretch; + } } @media (prefers-reduced-motion: reduce) { diff --git a/web/app.js b/web/app.js index e5caed7f0..53e23f2d6 100644 --- a/web/app.js +++ b/web/app.js @@ -2434,6 +2434,9 @@ var frags = { primary: document.createDocumentFragment(), advanced: document.createDocumentFragment() }; modes.forEach(function (m) { if (m.tier !== "primary" && m.tier !== "advanced") return; // skip hidden + // Household prefs on the Plan card replaced Passive/Active as + // the primary knobs. Catalog keys stay primary for HA/app. + if (String(m.key || "").indexOf("planner_") === 0) return; var btn = document.createElement("button"); btn.dataset.mode = m.key; btn.textContent = m.label; @@ -2443,6 +2446,7 @@ }); primary.replaceChildren(frags.primary); advanced.replaceChildren(frags.advanced); + primary.hidden = !primary.childElementCount; modeCatalogRendered = true; return true; }) diff --git a/web/index.html b/web/index.html index 986fb9376..14a7a2be8 100644 --- a/web/index.html +++ b/web/index.html @@ -530,19 +530,42 @@
Left keeps more in the battery if the sun might miss — closer to using the battery only for the house. Right follows the forecast fully. If the forecast is right, right earns more.
+Forecast haircut is set in config.yaml — that value wins over this slider.
+Solar can still export when this is off. Check your electricity contract.
+Not checked — battery export stays off.
+PV forecast safety k is not set in YAML. The Plan card slider maps cautious / balanced / bold to 2 / 1 / 0.
' + + ''; + } return '' + + '' + 'The planner requires working price + weather forecasts. When disabled the system runs in the manual mode set on the Control page.' + '
'; diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index b4717af6b..676b590fe 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -88,7 +88,39 @@ describe("render", () => { const html = tab.render(stubCtx()); assert.ok(html.includes('id="planner-active-strategy"')); assert.ok(html.includes('id="planner-hedge-line"')); - assert.ok(html.includes("Set from the Plan card on the dashboard")); + }); + + it("puts enabled, house reserve, and soc_max above a closed engine disclosure", () => { + const html = tab.render(stubCtx()); + const detailsAt = html.indexOf("No arrays defined — model will learn orientation from telemetry.
'; + host.innerHTML = 'No arrays defined. The model learns the production pattern from measured solar.
'; return; } var previewHtml = '