Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forecast-trust-export.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
37 changes: 32 additions & 5 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2322,6 +2345,7 @@ func main() {
Prices: priceSvc,
Forecast: forecastSvc,
MPC: mpcSvc,
PlannerPrefs: plannerPrefs,
PVModel: pvSvc,
LoadModel: loadSvc,
Loadpoints: lpMgr,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
16 changes: 16 additions & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go/internal/api/api_passthrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions go/internal/api/api_planner_prefs.go
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +77 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Downgrade before returning on persistence failure

When changing an actively arbitraging site to unknown or not_allowed, any SQLite write failure (for example, a full disk or closed database) returns here before the control mode is changed at line 91. The in-memory preference has already been updated, so /api/status reports that export is forbidden while planner_arbitrage can continue selling from the battery; apply the fail-safe mode transition before this fallible persistence path or roll back the preference and dispatch state together.

Useful? React with 👍 / 👎.

}
}
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)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefs remap clobbers planner modes

High Severity

applyPlannerPrefs remaps control mode whenever IsPlannerMode() is true, including planner_self and planner_cheap. Those modes are not part of the battery_export mapping, so saving prefs (even only to change forecast_trust) forces a switch to planner_passive_arbitrage or planner_arbitrage, which can enable grid charging that planner_self previously forbade.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53e8777. Configure here.

}
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
}
Loading