Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/plan-planning-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

The Plan card's forecast slider is now five planning styles, from Very careful to Very bold. A line under them says how much of the spare sun the plan counts on, and another says whether extra sun goes into the battery or to the grid. The default style, Balanced, holds back a smaller forecast margin than before (k 0.3 instead of 1), and a box that still runs the old default moves to it once. Settings → Planner fine-tunes the margin and saves at once. Changing the style or battery sales on one device no longer undoes a change made on another.
14 changes: 11 additions & 3 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,12 +589,20 @@ func main() {
yamlK = cfg.Planner.PVForecastSafetyK
}
trust, export, safetyK, missingPrefs := config.ResolvePlannerPrefs(storedTrust, storedExport, storedSafetyK, string(ctrl.Mode), yamlTrust, yamlExport, yamlK)
stylesDone, _ := st.LoadConfig(config.StateKeyPlanningStyles)
if k, first := config.MigrateOldDefaultSafetyK(safetyK, storedSafetyK, stylesDone); first {
if k != safetyK {
slog.Info("planner: old default forecast margin moved to the Balanced style", "from", safetyK, "to", k)
}
safetyK, trust, missingPrefs = k, config.TrustFromSafetyK(k), true
}
plannerPrefs := config.NewPlannerPrefs(trust, export, safetyK)
if missingPrefs {
if err := st.SaveConfigValues(map[string]string{
config.StateKeySafetyK: config.FormatSafetyK(safetyK),
config.StateKeyForecastTrust: string(trust),
config.StateKeyBatteryExport: string(export),
config.StateKeySafetyK: config.FormatSafetyK(safetyK),
config.StateKeyForecastTrust: string(trust),
config.StateKeyBatteryExport: string(export),
config.StateKeyPlanningStyles: "1",
}); err != nil {
slog.Error("save planner preferences", "err", err)
os.Exit(1)
Expand Down
4 changes: 4 additions & 0 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,10 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"control_feedback": s.controlFeedback(time.Now()),
"slot_delivery_stats": ctrl.SlotDeliveryStats,
}
// The operator's cap for storing live PV surplus, 0 when unset. Dispatch
// prefers it to the plan's per-slot cap, so the Plan card needs it to say
// where extra sun goes.
resp["pv_surplus_absorb_soc_cap"] = ctrl.PVSurplusAbsorbSoCCap
// A stale or missing site meter is not 0 W. Publishing zero made the
// dashboard and the FTW app draw "balanced" / "0 W" as if the house
// were idle. JSON null is what the flow mapping already treats as
Expand Down
53 changes: 43 additions & 10 deletions go/internal/api/api_planner_prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,42 +46,75 @@ func (s *Server) handleSetPlannerPrefs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
var safetyK float64
// Each control sends only what it changes; the box keeps the rest.
var safetyK *float64
if req.SafetyK != nil {
// New client: k wins and forecast_trust is whatever k derives to,
// so the two can never be posted into disagreement.
safetyK = config.ClampSafetyK(*req.SafetyK)
} else {
k := config.ClampSafetyK(*req.SafetyK)
safetyK = &k
} else if req.ForecastTrust != "" {
trust, ok := config.ParseForecastTrust(req.ForecastTrust)
if !ok || req.ForecastTrust == "" {
if !ok {
writeJSON(w, 400, map[string]string{"error": "forecast_trust must be cautious, balanced, or bold, or send safety_k"})
return
}
safetyK = trust.SafetyK()
k := trust.SafetyK()
safetyK = &k
}
var export *config.BatteryExport
if req.BatteryExport != "" {
e, ok := config.ParseBatteryExport(req.BatteryExport)
if !ok {
writeJSON(w, 400, map[string]string{"error": "battery_export must be unknown, not_allowed, or allowed"})
return
}
export = &e
}
export, ok := config.ParseBatteryExport(req.BatteryExport)
if !ok {
writeJSON(w, 400, map[string]string{"error": "battery_export must be unknown, not_allowed, or allowed"})
if safetyK == nil && export == nil {
writeJSON(w, 400, map[string]string{"error": "send safety_k, forecast_trust or battery_export"})
return
}
if err := s.applyPlannerPrefs(safetyK, export); err != nil {
saved, err := s.applyPlannerChange(safetyK, export)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
trust, _, resolvedK, mappedMode := s.plannerPrefsSnapshot()
writeJSON(w, 200, map[string]any{
"status": "ok",
"forecast_trust": trust,
"battery_export": export,
"battery_export": saved,
"safety_k": resolvedK,
"mapped_k": resolvedK,
"mapped_mode": mappedMode,
})
}

// applyPlannerChange writes what a client sent and keeps the other
// preference as the box holds it when the write runs. It reads under the same
// lock as every preference write, so two clients changing different
// preferences never undo each other.
func (s *Server) applyPlannerChange(safetyK *float64, export *config.BatteryExport) (config.BatteryExport, error) {
s.configWriteMu.Lock()
defer s.configWriteMu.Unlock()
_, e, k, _ := s.plannerPrefsSnapshot()
if safetyK != nil {
k = *safetyK
}
if export != nil {
e = *export
}
return e, s.applyPlannerPrefsLocked(k, e)
}

func (s *Server) applyPlannerPrefs(safetyK float64, export config.BatteryExport) error {
s.configWriteMu.Lock()
defer s.configWriteMu.Unlock()
return s.applyPlannerPrefsLocked(safetyK, export)
}

func (s *Server) applyPlannerPrefsLocked(safetyK float64, export config.BatteryExport) error {
safetyK = config.ClampSafetyK(safetyK)
trust := config.TrustFromSafetyK(safetyK)
mapped := control.Mode(export.PlannerModeKey())
Expand Down
61 changes: 61 additions & 0 deletions go/internal/api/api_planner_prefs_margin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package api

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

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

// A margin-only save must keep the export choice the box holds when the write
// runs, even if another client changed it after this one read; and the
// reverse for an export-only save.
func TestPostPlannerPrefsChangesOnlyWhatIsSent(t *testing.T) {
srv, ctrl, _ := plannerPrefsServer(t, control.ModePlannerArbitrage)
request := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/planner/prefs", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, req)
return rr
}
post := func(body string) map[string]any {
t.Helper()
rr := request(body)
if rr.Code != http.StatusOK {
t.Fatalf("POST %s: status=%d body=%s", body, rr.Code, rr.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
return got
}
post(`{"safety_k": 0.3, "battery_export": "allowed"}`)
post(`{"safety_k": 0.3, "battery_export": "not_allowed"}`) // another client turns sales off
got := post(`{"safety_k": 0.6}`)
if got["battery_export"] != "not_allowed" || got["safety_k"] != 0.6 {
t.Fatalf("margin-only save = %v", got)
}
if _, export, k := srv.deps.PlannerPrefs.Get(); export != config.BatteryExportNotAllowed || k != 0.6 {
t.Fatalf("stored export=%s k=%v", export, k)
}
if ctrl.Mode != control.ModePlannerPassiveArbitrage {
t.Fatalf("mode = %s, want battery sales to stay off", ctrl.Mode)
}
// The export switch on another client keeps this client's margin.
got = post(`{"battery_export": "allowed"}`)
if got["battery_export"] != "allowed" || got["safety_k"] != 0.6 {
t.Fatalf("export-only save = %v", got)
}
if ctrl.Mode != control.ModePlannerArbitrage {
t.Fatalf("mode = %s, want battery sales back on", ctrl.Mode)
}
if rr := request(`{}`); rr.Code != http.StatusBadRequest {
t.Fatalf("an empty change got %d, want 400", rr.Code)
}
}
23 changes: 23 additions & 0 deletions go/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,29 @@ func TestHandleStatusKeepsFaultedSiteMeterReading(t *testing.T) {
}
}

func TestHandleStatusReportsOperatorSurplusCap(t *testing.T) {
for _, cap := range []float64{0, 0.88} {
srv := New(&Deps{
Tel: telemetry.NewStore(),
Ctrl: &control.State{PVSurplusAbsorbSoCCap: cap},
CtrlMu: &sync.Mutex{},
CapMu: &sync.RWMutex{},
Capacities: map[string]float64{},
CfgMu: &sync.RWMutex{},
Cfg: &config.Config{},
})
rr := httptest.NewRecorder()
srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/status", nil))
var raw map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
if got, ok := raw["pv_surplus_absorb_soc_cap"]; !ok || got != cap {
t.Fatalf("pv_surplus_absorb_soc_cap = %v (sent %v), want %v", got, ok, cap)
}
}
}

func TestHandleStatusOmitsWattsWhenSiteMeterIsOffline(t *testing.T) {
tel := telemetry.NewStore()
ctrl := &control.State{SiteMeterDriver: "ferroamp"}
Expand Down
35 changes: 32 additions & 3 deletions go/internal/config/planner_prefs.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,24 @@ const (
StateKeyForecastTrust = "forecast_trust"
StateKeyBatteryExport = "battery_export"
StateKeySafetyK = "planner_safety_k"
// StateKeyPlanningStyles marks a box whose stored k has been through
// MigrateOldDefaultSafetyK, so the move happens once and never again.
StateKeyPlanningStyles = "planner_planning_styles"
)

// SafetyK bounds. 0 plans against the raw forecast; 2 holds back twice each
// slot's own forecast error. Above 2 the haircut erases the sunny shoulders
// outright, which is a worse plan, not a safer one.
//
// SafetyKDefault is the Plan card's Balanced style. The card's five styles
// live in web/plan-prefs.js (PLAN_STYLES); keep this value in step with it.
const (
SafetyKMin = 0.0
SafetyKMax = 2.0
SafetyKDefault = 1.0
SafetyKDefault = 0.3

// oldSafetyKDefault is the default every box stored before the styles.
oldSafetyKDefault = 1.0
)

// ForecastTrust is how hard the planner bets the PV/price forecast is right.
Expand Down Expand Up @@ -64,15 +73,17 @@ func ParseBatteryExport(s string) (BatteryExport, bool) {
}
}

// SafetyK is the PV downside haircut scale for this trust level.
// SafetyK is the PV downside haircut scale for this trust level. Balanced is
// the Plan card's Balanced style, so an old client asking for balanced gets
// the same plan as the card's default.
func (t ForecastTrust) SafetyK() float64 {
switch t {
case ForecastTrustCautious:
return 2.0
case ForecastTrustBold:
return 0.0
default:
return 1.0
return SafetyKDefault
}
}

Expand Down Expand Up @@ -206,6 +217,24 @@ func ResolvePlannerPrefs(storedTrust, storedExport, storedK, persistedMode, yaml
return trust, export, safetyK, missingStored
}

// MigrateOldDefaultSafetyK moves a box that still runs the old default k=1
// onto the Balanced style, once. k=1 was both the first-boot default and the
// old three-step "balanced", so a stored 1 means "the middle", and the middle
// is now SafetyKDefault. Any other stored value was chosen on the slider and
// stays. stored is the raw SQLite value: a k seeded from YAML on this boot is
// the operator's choice, not an old default. done is the
// StateKeyPlanningStyles marker; after the first call the caller stores it and
// this returns k unchanged forever.
func MigrateOldDefaultSafetyK(k float64, stored, done string) (float64, bool) {
if done != "" {
return k, false
}
if old, ok := ParseSafetyK(stored); ok && old == oldSafetyKDefault {
return SafetyKDefault, true
}
return k, true
}

// EffectiveSafetyK is the haircut scale the planner runs with, clamped to the
// slider's range. An explicit pv_forecast_safety_k in YAML does not win here:
// it seeds the first boot (ResolvePlannerPrefs) and nothing else — the Plan
Expand Down
45 changes: 36 additions & 9 deletions go/internal/config/planner_prefs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ 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 := ForecastTrustBalanced.SafetyK(); got != SafetyKDefault {
t.Errorf("balanced k=%v, want the Balanced style %v", got, SafetyKDefault)
}
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)
if got := ForecastTrust("").SafetyK(); got != SafetyKDefault {
t.Errorf("empty k=%v, want %v", got, SafetyKDefault)
}
}

Expand Down Expand Up @@ -67,12 +67,12 @@ func TestResolvePlannerPrefsStoredWins(t *testing.T) {

func TestResolvePlannerPrefsEnumOnlySiteKeepsItsK(t *testing.T) {
// A site upgraded from the three-step slider has no planner_safety_k
// row. Its plan must not move: k resolves to the step's own value and
// missingStored asks the caller to write the float.
// row. k resolves to the step's value, where balanced is the Plan card's
// Balanced style, and missingStored asks the caller to write the float.
for _, tc := range []struct {
trust string
wantK float64
}{{"cautious", 2}, {"balanced", 1}, {"bold", 0}} {
}{{"cautious", 2}, {"balanced", SafetyKDefault}, {"bold", 0}} {
trust, _, k, missing := ResolvePlannerPrefs(tc.trust, "allowed", "", "planner_passive_arbitrage", "", "", nil)
if k != tc.wantK {
t.Errorf("%s → k=%v, want %v", tc.trust, k, tc.wantK)
Expand Down Expand Up @@ -128,8 +128,8 @@ func TestResolvePlannerPrefsActiveUpgradeAsks(t *testing.T) {
if trust != ForecastTrustBalanced {
t.Errorf("trust=%s, want balanced", trust)
}
if k != 1 {
t.Errorf("k=%v, want 1", k)
if k != SafetyKDefault {
t.Errorf("k=%v, want the Balanced style %v", k, SafetyKDefault)
}
if export != BatteryExportUnknown {
t.Errorf("export=%s, want unknown (must confirm)", export)
Expand Down Expand Up @@ -201,3 +201,30 @@ func TestResolvePlannerPrefsSeedsFromYAMLK(t *testing.T) {
t.Fatalf("stored float must win, got %v", k)
}
}

func TestMigrateOldDefaultSafetyKMovesOnlyTheOldDefaultOnce(t *testing.T) {
for _, tc := range []struct {
name string
k float64
stored string
done string
wantK float64
wantFirst bool
}{
// k=1 was the first-boot default and the old "balanced": the middle.
{"old default", 1, "1", "", SafetyKDefault, true},
// A value picked on the slider stays where the household put it.
{"picked bold", 0.15, "0.15", "", 0.15, true},
{"picked careful", 2, "2", "", 2, true},
{"already balanced", SafetyKDefault, "0.3", "", SafetyKDefault, true},
// A new box seeds k from config.yaml this boot: the operator's choice.
{"seeded from YAML", 1, "", "", 1, true},
// Once the marker is stored, a later k=1 is a choice and stays.
{"after the move", 1, "1", "1", 1, false},
} {
k, first := MigrateOldDefaultSafetyK(tc.k, tc.stored, tc.done)
if k != tc.wantK || first != tc.wantFirst {
t.Errorf("%s: got (%v, %v), want (%v, %v)", tc.name, k, first, tc.wantK, tc.wantFirst)
}
}
}
Loading
Loading