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 @@

Plan

- +
- Strategy - -
+ Follow the forecast +
+ +
+ Hold reserve + Trust forecast +
+ +

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.

+ +
+
+ + +

Solar can still export when this is off. Check your electricity contract.

+ +
+

+ +
diff --git a/web/plan-prefs.js b/web/plan-prefs.js new file mode 100644 index 000000000..0b7280903 --- /dev/null +++ b/web/plan-prefs.js @@ -0,0 +1,100 @@ +// Household planner prefs for the Plan card: forecast trust slider, +// battery-export permission, and the four export sentences. +// Pure helpers — plan.js owns DOM and POST /api/planner/prefs. + +export const TRUST_STEPS = ["cautious", "balanced", "bold"]; + +const SALE_W = 100; + +export function sliderFromTrust(trust) { + const i = TRUST_STEPS.indexOf(trust); + return i >= 0 ? i : 1; +} + +export function trustFromSlider(value) { + const n = Number(value); + return TRUST_STEPS[n] || "balanced"; +} + +export function safetyK(trust) { + if (trust === "cautious") return 2; + if (trust === "bold") return 0; + return 1; +} + +export function hedgeLine(k, sigmaW) { + if (sigmaW == null || typeof sigmaW !== "number" || isNaN(sigmaW) || sigmaW < 0) return null; + const sigma = Math.round(sigmaW); + if (sigma < 1) return "σ right now ≈ 0 W — no hedge"; + let kn = parseFloat(k); + if (isNaN(kn) || kn < 0) kn = 0; + return "σ right now ≈ " + sigma + " W → hedge = k·σ ≈ " + Math.round(kn * sigma) + " W"; +} + +export function isBatterySale(action) { + return (Number(action && action.battery_w) || 0) < -SALE_W + && (Number(action && action.grid_w) || 0) < -SALE_W; +} + +export function isGridExport(action) { + return (Number(action && action.grid_w) || 0) < -SALE_W; +} + +function clock(ms) { + const d = new Date(ms); + return String(d.getHours()).padStart(2, "0") + ":" + + String(d.getMinutes()).padStart(2, "0"); +} + +export function batterySaleWindow(actions, nowMs) { + const list = Array.isArray(actions) ? actions : []; + const sale = list.filter(isBatterySale); + if (!sale.length) return null; + const now = nowMs == null ? Date.now() : nowMs; + const upcoming = sale.filter((a) => ( + a.slot_start_ms + a.slot_len_min * 60_000 > now + )); + const block = upcoming.length ? upcoming : sale; + let last = block[0]; + for (let i = 1; i < block.length; i++) { + const expected = last.slot_start_ms + last.slot_len_min * 60_000; + if (Math.abs(block[i].slot_start_ms - expected) > 1000) break; + last = block[i]; + } + const end = last.slot_start_ms + last.slot_len_min * 60_000; + return { start: clock(block[0].slot_start_ms), end: clock(end) }; +} + +export function exportSentence({ + actions = [], + exportPermission = "unknown", + nowMs = Date.now(), +} = {}) { + const window = batterySaleWindow(actions, nowMs); + if (window) { + return "Battery sale planned " + window.start + "–" + window.end + "."; + } + if (actions.some(isGridExport)) { + return "Solar export only; the battery is not selling."; + } + if (exportPermission === "allowed") { + return "Battery export is allowed, but FTW found no worthwhile sale."; + } + return "Battery sale blocked: permission is off or not checked."; +} + +export function prefsFromStatus(status) { + const s = status || {}; + const trust = TRUST_STEPS.includes(s.forecast_trust) ? s.forecast_trust : "balanced"; + const exp = s.battery_export; + return { + forecast_trust: trust, + battery_export: (exp === "allowed" || exp === "not_allowed" || exp === "unknown") + ? exp + : "unknown", + yaml_custom: !!s.planner_yaml_custom, + mapped_k: typeof s.planner_mapped_k === "number" && !isNaN(s.planner_mapped_k) + ? s.planner_mapped_k + : safetyK(trust), + }; +} diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs new file mode 100644 index 000000000..28103f27f --- /dev/null +++ b/web/plan-prefs.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import { + sliderFromTrust, + trustFromSlider, + safetyK, + hedgeLine, + isBatterySale, + exportSentence, + prefsFromStatus, +} from "./plan-prefs.js"; + +const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const app = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +const plan = readFileSync(new URL("./plan.js", import.meta.url), "utf8"); + +describe("forecast trust mapping", () => { + it("maps the three slider steps onto cautious / balanced / bold", () => { + assert.equal(trustFromSlider(0), "cautious"); + assert.equal(trustFromSlider(1), "balanced"); + assert.equal(trustFromSlider(2), "bold"); + assert.equal(trustFromSlider("1"), "balanced"); + assert.equal(sliderFromTrust("cautious"), 0); + assert.equal(sliderFromTrust("balanced"), 1); + assert.equal(sliderFromTrust("bold"), 2); + assert.equal(sliderFromTrust("nope"), 1); + }); + + it("maps trust only onto PV safety k 2 / 1 / 0", () => { + assert.equal(safetyK("cautious"), 2); + assert.equal(safetyK("balanced"), 1); + assert.equal(safetyK("bold"), 0); + }); +}); + +describe("hedge line", () => { + it("formats k·σ in watts", () => { + assert.equal(hedgeLine(1, 432.16), "σ right now ≈ 432 W → hedge = k·σ ≈ 432 W"); + assert.equal(hedgeLine(2, 432.16), "σ right now ≈ 432 W → hedge = k·σ ≈ 864 W"); + assert.equal(hedgeLine(0, 432.16), "σ right now ≈ 432 W → hedge = k·σ ≈ 0 W"); + }); + + it("hides when σ is missing", () => { + assert.equal(hedgeLine(1, null), null); + assert.equal(hedgeLine(1, -1), null); + }); +}); + +describe("export sentences", () => { + const noon = Date.UTC(2026, 7, 21, 10, 0, 0); + const slot = (start, battery_w, grid_w) => ({ + slot_start_ms: start, + slot_len_min: 15, + battery_w, + grid_w, + }); + + it("names a planned battery sale window", () => { + const actions = [ + slot(noon, -2000, -1500), + slot(noon + 15 * 60_000, -1800, -1200), + ]; + const text = exportSentence({ actions, exportPermission: "allowed", nowMs: noon }); + assert.match(text, /^Battery sale planned \d{2}:\d{2}–\d{2}:\d{2}\.$/); + }); + + it("reports solar export when the battery is not selling", () => { + const actions = [slot(noon, 0, -800)]; + assert.equal( + exportSentence({ actions, exportPermission: "allowed", nowMs: noon }), + "Solar export only; the battery is not selling.", + ); + }); + + it("reports no worthwhile sale when export is allowed and nothing exports", () => { + const actions = [slot(noon, 500, 200)]; + assert.equal( + exportSentence({ actions, exportPermission: "allowed", nowMs: noon }), + "Battery export is allowed, but FTW found no worthwhile sale.", + ); + }); + + it("reports a blocked sale when permission is off or unknown", () => { + const actions = [slot(noon, 0, 100)]; + assert.equal( + exportSentence({ actions, exportPermission: "not_allowed", nowMs: noon }), + "Battery sale blocked: permission is off or not checked.", + ); + assert.equal( + exportSentence({ actions, exportPermission: "unknown", nowMs: noon }), + "Battery sale blocked: permission is off or not checked.", + ); + }); + + it("does not treat house-only discharge as a battery sale", () => { + assert.equal(isBatterySale({ battery_w: -2000, grid_w: 300 }), false); + assert.equal(isBatterySale({ battery_w: -2000, grid_w: -400 }), true); + }); +}); + +describe("prefsFromStatus", () => { + it("defaults to balanced + unknown", () => { + const p = prefsFromStatus({}); + assert.equal(p.forecast_trust, "balanced"); + assert.equal(p.battery_export, "unknown"); + assert.equal(p.yaml_custom, false); + assert.equal(p.mapped_k, 1); + }); + + it("passes through yaml_custom and mapped_k", () => { + const p = prefsFromStatus({ + forecast_trust: "bold", + battery_export: "allowed", + planner_yaml_custom: true, + planner_mapped_k: 0.25, + }); + assert.equal(p.forecast_trust, "bold"); + assert.equal(p.battery_export, "allowed"); + assert.equal(p.yaml_custom, true); + assert.equal(p.mapped_k, 0.25); + }); +}); + +describe("Plan card markup and wiring", () => { + it("puts follow-the-forecast on the Plan card, not Passive/Active as primary", () => { + assert.match(html, /id="forecast-trust-slider"/); + assert.match(html, /Hold reserve/); + assert.match(html, /Trust forecast/); + assert.match(html, /Follow the forecast/); + assert.match(html, /id="plan-export-check"/); + assert.match( + html, + /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\./, + ); + assert.match( + html, + /Allow the battery to sell to the grid when the plan expects a worthwhile sale\./, + ); + assert.match( + html, + /Solar can still export when this is off\. Check your electricity contract\./, + ); + assert.match(html, /Not checked — battery export stays off\./); + assert.match(html, /FTW used to sell from the battery on high-price hours\. Allow that to continue\?/); + assert.doesNotMatch(html, />Strategy r.json()).catch(() => ({})), apiFetch('/api/forecast').then(r => r.json()).catch(() => ({})), apiFetch('/api/mpc/plan').then(r => r.json()).catch(() => ({})), apiFetch('/api/config').then(r => r.json()).catch(() => ({})), apiFetch('/api/status').then(r => r.json()).catch(() => ({})), + apiFetch('/api/pvmodel').then(r => r.json()).catch(() => ({})), ]); state.prices = (p && p.items) || []; // /api/prices says which currency the stored minor units are in, so @@ -140,6 +149,10 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. // stacked as spot + grid tariff + VAT instead of one opaque number. state.priceCfg = (c && c.price) || null; state.status = s || {}; + state.prefs = prefsFromStatus(s); + state.pvSigmaW = (pv && typeof pv.pv_residual_std_w === "number") + ? pv.pv_residual_std_w + : null; state.enabled = { prices: p && p.enabled, forecast: f && f.enabled, @@ -219,6 +232,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. setText('plan-expected-soc', brief.soc ? brief.soc.label : '—'); setText('plan-soc-detail', brief.soc ? brief.soc.detail : ''); renderOverviewPlanBrief(brief); + syncPrefsUI(); } function renderOptimizerFallbackAlert(plan) { @@ -987,6 +1001,146 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. canvas.addEventListener('touchcancel', endTouch); } + // Household prefs (forecast trust + battery export) on the Plan card. + // The slider POSTs trust only; export is sent unchanged so moving the + // slider never turns on battery export. + let trustDirty = false; + let prefsPosting = false; + + function currentPrefs() { + return state.prefs || prefsFromStatus(state.status); + } + + function mappedK() { + const p = currentPrefs(); + if (typeof p.mapped_k === "number") return p.mapped_k; + return safetyK(p.forecast_trust); + } + + function syncPrefsUI() { + const p = currentPrefs(); + const slider = document.getElementById("forecast-trust-slider"); + if (slider && !trustDirty) { + slider.value = String(sliderFromTrust(p.forecast_trust)); + slider.disabled = !!p.yaml_custom; + slider.setAttribute("aria-valuenow", slider.value); + slider.setAttribute("aria-valuetext", p.forecast_trust); + } + const yamlNote = document.getElementById("forecast-trust-yaml"); + if (yamlNote) yamlNote.hidden = !p.yaml_custom; + + const hedgeEl = document.getElementById("forecast-trust-hedge"); + if (hedgeEl) { + const kUse = p.yaml_custom + ? mappedK() + : (slider ? safetyK(trustFromSlider(slider.value)) : mappedK()); + const text = hedgeLine(kUse, state.pvSigmaW); + if (text == null) { + hedgeEl.hidden = true; + hedgeEl.textContent = ""; + } else { + hedgeEl.hidden = false; + hedgeEl.textContent = text; + } + } + + const unknown = p.battery_export === "unknown"; + const banner = document.getElementById("plan-export-banner"); + const row = document.getElementById("plan-export-row"); + const unknownHelp = document.getElementById("plan-export-unknown"); + const check = document.getElementById("plan-export-check"); + if (banner) banner.hidden = !unknown; + if (row) row.hidden = unknown; + if (unknownHelp) unknownHelp.hidden = !unknown; + if (check && !unknown) check.checked = p.battery_export === "allowed"; + + const sentence = document.getElementById("plan-export-sentence"); + if (sentence) { + const actions = (state.plan && state.plan.actions) || []; + sentence.textContent = exportSentence({ + actions, + exportPermission: p.battery_export, + }); + } + } + + async function postPlannerPrefs(trust, exportPerm) { + if (prefsPosting) return; + prefsPosting = true; + try { + const r = await apiFetch("/api/planner/prefs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + forecast_trust: trust, + battery_export: exportPerm, + }), + }); + if (!r.ok) throw new Error("HTTP " + r.status); + const j = await r.json(); + state.prefs = { + forecast_trust: j.forecast_trust, + battery_export: j.battery_export, + yaml_custom: !!j.yaml_custom, + mapped_k: typeof j.mapped_k === "number" ? j.mapped_k : safetyK(j.forecast_trust), + }; + trustDirty = false; + syncPrefsUI(); + fetchAll(); + } catch (e) { + trustDirty = false; + syncPrefsUI(); + } finally { + prefsPosting = false; + } + } + + function initPrefs() { + const slider = document.getElementById("forecast-trust-slider"); + if (slider) { + slider.addEventListener("input", function () { + if (slider.disabled) return; + trustDirty = true; + const hedgeEl = document.getElementById("forecast-trust-hedge"); + if (hedgeEl) { + const text = hedgeLine(safetyK(trustFromSlider(slider.value)), state.pvSigmaW); + if (text == null) { + hedgeEl.hidden = true; + } else { + hedgeEl.hidden = false; + hedgeEl.textContent = text; + } + } + }); + slider.addEventListener("change", function () { + if (slider.disabled) return; + const p = currentPrefs(); + postPlannerPrefs(trustFromSlider(slider.value), p.battery_export); + }); + } + const check = document.getElementById("plan-export-check"); + if (check) { + check.addEventListener("change", function () { + const p = currentPrefs(); + postPlannerPrefs(p.forecast_trust, check.checked ? "allowed" : "not_allowed"); + }); + } + const allow = document.getElementById("plan-export-allow"); + if (allow) { + allow.addEventListener("click", function () { + const p = currentPrefs(); + postPlannerPrefs(p.forecast_trust, "allowed"); + }); + } + const deny = document.getElementById("plan-export-deny"); + if (deny) { + deny.addEventListener("click", function () { + const p = currentPrefs(); + postPlannerPrefs(p.forecast_trust, "not_allowed"); + }); + } + } + // Strategy explanation — surfaces one-sentence logic for the current mode. const STRATEGY_DESC = { planner_passive_arbitrage: 'Passive arbitrage. Charges the battery from the cheapest available energy each slot — PV when sunny, grid during cheap night hours — for your own use. Never exports from the battery. Subsumes smart self-consumption (summer behavior) and cheap charging (winter behavior); the planner picks per slot.', @@ -1033,6 +1187,10 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. el.textContent = copy.action + '. ' + copy.nextStep + '.'; return; } + if (plannerMode) { + el.textContent = ''; + return; + } el.textContent = STRATEGY_DESC[d.mode] || ''; }) .catch(function () {}); @@ -1041,6 +1199,7 @@ import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units. function init() { fetchAll(); setupHover(); + initPrefs(); renderStrategyHint(); setInterval(fetchAll, PLAN_REFRESH_MS); setInterval(renderStrategyHint, 5000); diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js index 1dce496fe..95ccfbd9f 100644 --- a/web/settings/tabs/planner.js +++ b/web/settings/tabs/planner.js @@ -62,14 +62,33 @@ } delete planner.soc_min_pct; delete planner.soc_max_pct; + var kHtml; + if (planner.pv_forecast_safety_k != null) { + kHtml = field("PV forecast safety (k)", "planner.pv_forecast_safety_k", "number", 1.0, + "How much the planner trusts the solar forecast. It plans against forecast − k×σ, where σ is the live PV-forecast error. Higher k = trust the forecast less: the battery holds more reserve and charges earlier, drifting toward self-consumption behaviour. 0 = trust the forecast fully (no hedge). On clear, stable days σ shrinks toward zero and k has little effect.") + + ''; + } else { + kHtml = '

PV forecast safety k is not set in YAML. The Plan card slider maps cautious / balanced / bold to 2 / 1 / 0.

' + + ''; + } return '
MPC Planner' + '' + - '
' + + '
' + + 'Engine controls — leave these unless you are debugging.' + + '
Engine' + + '' + - '
' + - '

Set from the Plan card on the dashboard — not editable here.

' + + '
' + '
' + selectField("Engine", "planner.engine", ["python", "dp"], "python", "Python runs the CVXPY mathematical optimizer. DP is the emergency rollback engine.") + @@ -128,18 +147,7 @@ field("Decomposition threshold", "planner.optimizer_multistage.decomposition_threshold", "number", 20, "Scenario count above which auto mode uses eligible Progressive Hedging or reduces to the exact extensive budget.") + '
' + - '
' + - field("Min SoC (0–1)", "planner.soc_min", "number", 0.10, - "Lowest SoC the planner will discharge to. 0.10 = 10%.") + - '
' + - field("Max SoC (0–1)", "planner.soc_max", "number", 0.90, - "Highest SoC the planner will charge to. 0.90 = 90%.") + - '
' + - '
' + - field("PV forecast safety (k)", "planner.pv_forecast_safety_k", "number", 1.0, - "How much the planner trusts the solar forecast. It plans against forecast − k×σ, where σ is the live PV-forecast error. Higher k = trust the forecast less: the battery holds more reserve and charges earlier, drifting toward self-consumption behaviour. 0 = trust the forecast fully (no hedge). On clear, stable days σ shrinks toward zero and k has little effect — the hedge sizes itself to the real risk.") + - '' + - '
' + + '
' + kHtml + '
' + '
' + field("Base load (W)", "planner.base_load_w", "number", 0, "Constant household load estimate used when the load twin has no data yet.") + @@ -166,6 +174,7 @@ "The battery won't cycle for grid arbitrage unless the price gain beats this many öre/kWh, on top of round-trip losses. 0 = off. Higher = fewer, deeper cycles. Self-consumption is never affected. Tune empirically.") + '
' + '
' + + '
' + '

' + '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(" 0); + const top = html.slice(0, detailsAt); + const rest = html.slice(detailsAt); + assert.ok(top.includes('data-checkbox-path="planner.enabled"')); + assert.ok(top.includes("[field:planner.soc_min]")); + assert.ok(top.includes("[field:planner.soc_max]")); + assert.ok(!top.includes("[select:planner.engine]")); + assert.ok(!top.includes("CLARABEL")); + assert.ok(!top.includes("[select:planner.optimizer_solver]")); + assert.match(rest, /
/); + assert.doesNotMatch(html, /]*\sopen\b/); + assert.ok(rest.includes("Engine controls — leave these unless you are debugging.")); + assert.ok(rest.includes("[select:planner.engine]")); + assert.ok(rest.includes("[select:planner.optimizer_solver]")); + assert.ok(rest.includes("[field:planner.optimizer_cvar_weight]")); + }); + + it("does not bind pv_forecast_safety_k when YAML left it unset", () => { + const html = tab.render(stubCtx()); + assert.ok(!html.includes("[field:planner.pv_forecast_safety_k]")); + }); + + it("binds pv_forecast_safety_k inside engine details when YAML set it", () => { + const ctx = stubCtx(); + ctx.config.planner = { pv_forecast_safety_k: 0.25 }; + const html = tab.render(ctx); + const rest = html.slice(html.indexOf(" { diff --git a/web/settings/tabs/weather.js b/web/settings/tabs/weather.js index 7da52bf55..7924c994a 100644 --- a/web/settings/tabs/weather.js +++ b/web/settings/tabs/weather.js @@ -67,7 +67,7 @@ var arrays = (config.weather && config.weather.pv_arrays) || []; arrays.forEach(migrateArrayRatedW); if (arrays.length === 0) { - host.innerHTML = '

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 = '
0) { + return count === 1 ? "1 array set in config" : count + " arrays set in config"; + } + return "The solar production pattern is learned from measured solar."; + } + + function refreshArraysSummary(config) { + var el = document.getElementById("pv-arrays-summary"); + if (!el) return; + var n = ((config.weather && config.weather.pv_arrays) || []).length; + el.textContent = arraysSummary(n); + } + function initWeatherMap(ctx) { var container = document.getElementById("weather-map"); if (!container) return; @@ -180,9 +196,10 @@ var field = ctx.field, selectField = ctx.selectField, help = ctx.help, config = ctx.config; if (!config.weather) config.weather = { latitude: 59.3293, longitude: 18.0686 }; if (!Array.isArray(config.weather.pv_arrays)) config.weather.pv_arrays = []; + var n = config.weather.pv_arrays.length; return '
Weather forecast & PV' + selectField("Provider", "weather.provider", ["met_no", "openweather", "open_meteo", "forecast_solar", "none"], "met_no", - "met_no + openweather: cloud-cover only. open_meteo: direct shortwave radiation (better day-one forecast). forecast_solar: site-calibrated watts using the panel geometry below (best with multi-array setups).") + + "met_no + openweather: cloud-cover only. open_meteo: direct shortwave radiation (better day-one forecast). forecast_solar: site-calibrated watts. The production pattern is learned from measured solar.") + '
' + field("Latitude", "weather.latitude", "number", 59.3293) + '
' + @@ -193,25 +210,33 @@ field("PV rated (W)", "weather.pv_rated_w", "number", 10000) + field("API key (OpenWeather only)", "weather.api_key", "text", "") + '
' + + '

' + + arraysSummary(n) + '

' + + '
' + + 'Advanced array geometry — leave this unless you are debugging.' + '
PV arrays ' + help( 'Optional. Open-Meteo uses these per-plane values to project shortwave radiation onto each array. ' + - 'Forecast.Solar uses them for its site-calibrated forecast. Leave empty for the safe flat estimate or the provider default.') + '' + + 'Forecast.Solar uses them for its site-calibrated forecast. Leave empty unless you are debugging a multi-plane site.') + '' + '
' + '' + '

' + 'Tilt: 0° = flat roof, 35° = typical pitched roof, 90° = wall. Azimuth: 0 = N, 90 = E, 180 = S, 270 = W. ' + 'Rated (W) is watts, same unit as PV rated.' + '

' + - '
'; + '
'; }, after: function (ctx) { initWeatherMap(ctx); renderPVArrays(ctx); + refreshArraysSummary(ctx.config); var addBtn = document.getElementById("pv-array-add"); if (addBtn) addBtn.addEventListener("click", function () { ctx.config.weather.pv_arrays.push({ name: "", rated_w: 0, tilt_deg: 35, azimuth_deg: 180 }); renderPVArrays(ctx); + refreshArraysSummary(ctx.config); }); }, }; + + S.tabs.weather._pure = { arraysSummary: arraysSummary }; })(); diff --git a/web/settings/tabs/weather.test.mjs b/web/settings/tabs/weather.test.mjs index 073b5fc3d..592fcd781 100644 --- a/web/settings/tabs/weather.test.mjs +++ b/web/settings/tabs/weather.test.mjs @@ -4,6 +4,21 @@ import { describe, it } from "node:test"; const source = readFileSync(new URL("./weather.js", import.meta.url), "utf8"); +globalThis.window = {}; +await import("./weather.js"); +const tab = globalThis.window.FTWSettings.tabs.weather; +const { arraysSummary } = tab._pure; + +function stubCtx(weather) { + return { + config: { weather: weather || {} }, + field: (label, path) => "[field:" + path + "]", + selectField: (label, path) => "[select:" + path + "]", + help: () => "[?]", + escHtml: (s) => String(s == null ? "" : s), + }; +} + describe("weather PV array nameplate", () => { it("binds Rated (W) to rated_w, not kwp", () => { assert.match(source, /Rated \(W\)/); @@ -21,3 +36,49 @@ describe("weather PV array nameplate", () => { assert.match(source, /delete a\.kwp/); }); }); + +describe("weather household path", () => { + it("summarizes measured solar vs existing arrays", () => { + assert.equal( + arraysSummary(0), + "The solar production pattern is learned from measured solar.", + ); + assert.equal(arraysSummary(1), "1 array set in config"); + assert.equal(arraysSummary(3), "3 arrays set in config"); + }); + + it("keeps the map and keeps Add + orientation off the normal path", () => { + const html = tab.render(stubCtx()); + const detailsAt = html.indexOf(" 0); + const normal = html.slice(0, detailsAt); + const rest = html.slice(detailsAt); + assert.ok(normal.includes('id="weather-map"')); + assert.ok(normal.includes("[field:weather.pv_rated_w]")); + assert.ok(normal.includes("The solar production pattern is learned from measured solar.")); + assert.doesNotMatch(normal, /pv-array-add/); + assert.doesNotMatch(normal, /\+ Add array/); + assert.doesNotMatch(normal, /orientation/i); + assert.match(rest, /
]*\sopen\b/); + assert.ok(rest.includes('id="pv-array-add"')); + assert.ok(rest.includes("+ Add array")); + }); + + it("shows a read-only count when arrays already exist", () => { + const html = tab.render(stubCtx({ + pv_arrays: [ + { name: "south", rated_w: 4000, tilt_deg: 35, azimuth_deg: 180 }, + { name: "east", rated_w: 2000, tilt_deg: 35, azimuth_deg: 90 }, + ], + })); + const normal = html.slice(0, html.indexOf(" { + assert.doesNotMatch(source, /pv_rated_w\s*=/); + assert.doesNotMatch(source, /observed.*pv_rated_w|peak.*pv_rated_w/i); + }); +});