diff --git a/.changeset/plan-planning-styles.md b/.changeset/plan-planning-styles.md new file mode 100644 index 000000000..d9250f951 --- /dev/null +++ b/.changeset/plan-planning-styles.md @@ -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. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 6a4215174..4d74b2a43 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 4628be87b..10b043fc6 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -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 diff --git a/go/internal/api/api_planner_prefs.go b/go/internal/api/api_planner_prefs.go index 9af54681a..4e3f812a3 100644 --- a/go/internal/api/api_planner_prefs.go +++ b/go/internal/api/api_planner_prefs.go @@ -46,25 +46,37 @@ 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 } @@ -72,16 +84,37 @@ func (s *Server) handleSetPlannerPrefs(w http.ResponseWriter, r *http.Request) { 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()) diff --git a/go/internal/api/api_planner_prefs_margin_test.go b/go/internal/api/api_planner_prefs_margin_test.go new file mode 100644 index 000000000..faa89ef18 --- /dev/null +++ b/go/internal/api/api_planner_prefs_margin_test.go @@ -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) + } +} diff --git a/go/internal/api/api_test.go b/go/internal/api/api_test.go index 796053cda..a6b80c873 100644 --- a/go/internal/api/api_test.go +++ b/go/internal/api/api_test.go @@ -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"} diff --git a/go/internal/config/planner_prefs.go b/go/internal/config/planner_prefs.go index f32a7c95b..9451f472d 100644 --- a/go/internal/config/planner_prefs.go +++ b/go/internal/config/planner_prefs.go @@ -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. @@ -64,7 +73,9 @@ 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: @@ -72,7 +83,7 @@ func (t ForecastTrust) SafetyK() float64 { case ForecastTrustBold: return 0.0 default: - return 1.0 + return SafetyKDefault } } @@ -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 diff --git a/go/internal/config/planner_prefs_test.go b/go/internal/config/planner_prefs_test.go index b011680a3..a1337b80b 100644 --- a/go/internal/config/planner_prefs_test.go +++ b/go/internal/config/planner_prefs_test.go @@ -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) } } @@ -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) @@ -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) @@ -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) + } + } +} diff --git a/web/app.css b/web/app.css index 3c3b102a4..c7eddb646 100644 --- a/web/app.css +++ b/web/app.css @@ -1067,19 +1067,52 @@ body.ftw-app .strategy-hint { body.ftw-app .strategy-hint:empty { display: none; } -body.ftw-app .forecast-trust { +body.ftw-app .plan-style { margin: 0 0 12px; } -body.ftw-app .forecast-trust input[type="range"] { - width: 100%; - accent-color: var(--accent-e); +/* Five equal steps, like a fund's risk scale: one row, the chosen step + filled. Names wrap to two lines on a phone rather than shrink. */ +body.ftw-app .plan-style-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; +} +body.ftw-app .plan-style-steps button { + min-height: 44px; margin: 0; + padding: 6px 4px; + border: 0; + border-left: 1px solid var(--line); + border-radius: 0; + background: var(--ink-sunken); + color: var(--fg-dim); + cursor: pointer; + font-family: var(--sans); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + hyphens: none; + word-break: keep-all; } -body.ftw-app .forecast-trust input[type="range"]:disabled { - opacity: 0.45; - cursor: not-allowed; +body.ftw-app .plan-style-steps button:first-child { + border-left: 0; +} +body.ftw-app .plan-style-steps button:hover { + background: var(--ink-elevated); + color: var(--fg); +} +body.ftw-app .plan-style-steps button[aria-checked="true"] { + background: oklch(0.3 0.1 var(--accent-hue) / 0.35); + box-shadow: inset 0 0 0 1px var(--accent-e); + color: var(--fg); +} +body.ftw-app .plan-style-steps button:focus-visible { + outline: 2px solid var(--accent-e); + outline-offset: -3px; } -body.ftw-app .forecast-trust-labels { +body.ftw-app .plan-style-ends { display: flex; justify-content: space-between; gap: 8px; @@ -1088,20 +1121,40 @@ body.ftw-app .forecast-trust-labels { font-family: var(--sans); font-size: 12px; } -body.ftw-app .forecast-trust-labels #forecast-trust-value { +body.ftw-app .plan-style-ends span:last-child { + text-align: right; +} +body.ftw-app .plan-style-text, +body.ftw-app .plan-style-sun, +body.ftw-app .plan-style-extra, +body.ftw-app .plan-style-note, +body.ftw-app .plan-style-status { + margin: 8px 0 0; + font-family: var(--sans); + font-size: 13px; + line-height: 1.45; +} +body.ftw-app .plan-style-text, +body.ftw-app .plan-style-sun { color: var(--fg); - font-family: var(--mono); - font-variant-numeric: tabular-nums; } -body.ftw-app .forecast-trust-hedge { - margin-top: 8px; +body.ftw-app .plan-style-extra, +body.ftw-app .plan-style-note { 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-style-note .btn-link { + font-family: var(--sans); + font-size: 13px; +} +body.ftw-app .plan-style-status { + color: var(--red-e); +} +/* The numbers describe the plan that is still running until the new one + lands, so they step back while the box replans. */ +body.ftw-app .plan-style.is-replanning .plan-style-sun, +body.ftw-app .plan-style.is-replanning .plan-style-extra { + opacity: 0.55; +} body.ftw-app .plan-export-help, body.ftw-app .plan-export-unknown { margin: 8px 0 0; @@ -1170,7 +1223,7 @@ body.ftw-app .plan-export-sentence { that width read as a banner, not as something to touch, and prose set across it is hard to follow — so both get a comfortable measure and the rest of the row stays empty. */ -body.ftw-app .plan-strategy .forecast-trust, +body.ftw-app .plan-strategy .plan-style, body.ftw-app .plan-strategy .plan-export, body.ftw-app .plan-strategy .plan-export-banner, body.ftw-app .plan-strategy .plan-export-row, @@ -1178,8 +1231,6 @@ body.ftw-app .plan-strategy .mode-advanced-toggle, body.ftw-app .plan-strategy #mode-buttons { max-width: 560px; } -body.ftw-app .plan-strategy .forecast-trust-help, -body.ftw-app .plan-strategy .forecast-trust-yaml, body.ftw-app .plan-strategy .plan-export-help, body.ftw-app .plan-strategy .plan-export-unknown, body.ftw-app .plan-strategy .plan-export-sentence, @@ -2729,11 +2780,22 @@ 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-style-text, + body.ftw-app .plan-style-sun, + body.ftw-app .plan-style-extra, + body.ftw-app .plan-style-note, body.ftw-app .plan-export-help, body.ftw-app .plan-export-sentence { font-size: 12px; } + /* Five names across a phone: smaller type keeps "Balanced" whole. */ + body.ftw-app .plan-style-steps button { + padding: 6px 2px; + font-size: 11px; + } + body.ftw-app .plan-style-ends { + font-size: 11px; + } body.ftw-app .plan-export-banner-actions { flex-direction: column; align-items: stretch; diff --git a/web/index.html b/web/index.html index a997f7907..7c52f39d3 100644 --- a/web/index.html +++ b/web/index.html @@ -540,21 +540,32 @@

Plan

Passive/Active are no longer primary buttons (HA/app keep those keys). Manual fallbacks stay behind Manual…. -->
- Follow the forecast - -
- -
- Trust forecast - k 1 - Hold reserve + Planning style + +
+
+ + + + +
- -

Left follows the forecast fully — if it is right, that earns more. Right keeps more in the battery in case the sun misses, closer to using the battery only for the house. Every notch changes how much of each slot's own forecast error the plan holds back.

+ + + + +

+ + Safety limits are the same in every style. + +

+
' + + fineHtml + '' + '
' + 'Engine controls — leave these unless you are debugging.' + @@ -101,7 +162,7 @@ engineSelect(planner.engine, help) + '
' + '

Energyplan uses a 500 ms solve limit. Core DP runs in the background for comparison and supplies a fallback if needed.

' + - '
' + kHtml + '
' + + seedHtml + '
' + field("Base load (W)", "planner.base_load_w", "number", 0, "Constant household load estimate used when the load twin has no data yet.") + @@ -155,29 +216,105 @@ }); } - // ---- Live σ/hedge readout under the k field ---- - var hedgeEl = document.getElementById("planner-hedge-line"); - var kInput = document.querySelector('input[data-path="planner.pv_forecast_safety_k"]'); - if (hedgeEl && kInput) { - apiFetch("/api/pvmodel") - .then(function (r) { return r.json(); }) - .then(function (d) { - if (!d || d.enabled === false) return; // pvmodel off → line stays hidden - var sigma = d.pv_residual_std_w; - function update() { - var text = hedgeLine(kInput.value, sigma); - if (text == null) return; - hedgeEl.textContent = text; - hedgeEl.style.display = ""; - } - update(); - kInput.addEventListener("input", update); + // ---- Forecast margin: read, fine-tune, save at once ---- + var kInput = document.getElementById("planner-style-k"); + var kValue = document.getElementById("planner-style-k-value"); + var kNote = document.getElementById("planner-style-k-note"); + var marginEl = document.getElementById("planner-margin-line"); + if (kInput && kValue && kNote) { + var lib = window.FTWPlanPrefs || null; + var show = function (k, prefix) { + kValue.textContent = "k " + formatK(k); + kNote.textContent = (prefix || "") + styleNote(k, lib); + }; + var dragging = false; + // Paints the page's margin state, unless a drag is under way. + paintMargin = function () { + if (!kInput.isConnected || dragging) return; + var v = margin.view(); + if (v.k == null) return; + kInput.value = String(v.k); + show(v.k, v.state === "saving" ? "Saving… " : v.state === "saved" ? "Saved. " : ""); + if (v.state === "failed") kNote.textContent = "Not saved: the box did not answer. Try again."; + }; + if (!marginListening) { + marginListening = true; + // Every write the box confirms, from the Plan card or from here. + window.addEventListener("ftw-planner-prefs", function (e) { + var p = e.detail || {}; + var k = typeof p.safety_k === "number" ? p.safety_k : p.mapped_k; + if (typeof k !== "number") return; + margin.confirm(k); + if (paintMargin) paintMargin(); + }); + } + paintMargin(); // what the page already knows, before the read answers + var readRev = margin.beginRead(); + apiFetch("/api/planner/prefs") + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (p) { + kInput.disabled = false; + margin.endRead(readRev, typeof p.safety_k === "number" ? p.safety_k : p.mapped_k); + paintMargin(); }) - .catch(function () {}); // unreachable → line stays hidden + .catch(function () { kNote.textContent = "The box did not answer. Reopen Settings to try again."; }); + // A drag is a pointer held on the slider. Chrome sends no change when + // a drag ends where it began, so the release ends it too. The repaint + // waits a turn, after the change a moved thumb sends. + var endDrag = function () { + if (!dragging) return; + dragging = false; + setTimeout(function () { if (paintMargin) paintMargin(); }, 0); + }; + kInput.addEventListener("pointerdown", function () { dragging = true; }); + ["pointerup", "pointercancel", "lostpointercapture", "blur"].forEach(function (type) { + kInput.addEventListener(type, endDrag); + }); + kInput.addEventListener("input", function () { show(kInput.value); }); + kInput.addEventListener("change", function () { + dragging = false; + var k = Number(kInput.value); + margin.request(k); + paintMargin(); + savePlannerMargin(apiFetch, k) + .then(function () { return true; }, function () { return false; }) + .then(function (ok) { + margin.settle(ok); + if (paintMargin) paintMargin(); + }); + }); + // The same margin as the Plan card, split into sun and use. It + // follows each plan the Plan card fetches, so a save shows up here. + if (marginEl && lib) { + var showMargin = function (actions) { + if (!actions || !actions.length) { + // No plan now: an old margin must not read as the current one. + marginEl.textContent = ""; + marginEl.hidden = true; + return; + } + var last = actions[actions.length - 1]; + var text = lib.marginSplitLine(lib.forecastMargins(actions, Date.now() - 30 * 60 * 1000, + last.slot_start_ms + last.slot_len_min * 60 * 1000)); + marginEl.textContent = text || ""; + marginEl.hidden = !text; + }; + var tab = S.tabs.planner; + if (tab._onPlan) window.removeEventListener("ftw-plan-data", tab._onPlan); + tab._onPlan = function (e) { + if (marginEl.isConnected) showMargin(e.detail && e.detail.plan && e.detail.plan.actions); + }; + window.addEventListener("ftw-plan-data", tab._onPlan); + apiFetch("/api/mpc/plan") + .then(function (r) { return r.json(); }) + .then(function (m) { showMargin(m && m.plan && m.plan.actions); }) + .catch(function () {}); + } } }, }; // Escape hatch for node --test (planner.test.mjs); not a public API. - S.tabs.planner._pure = { strategyLabel: strategyLabel, hedgeLine: hedgeLine, engineSelect: engineSelect }; + S.tabs.planner._pure = { strategyLabel: strategyLabel, styleNote: styleNote, formatK: formatK, engineSelect: engineSelect, + marginModel: marginModel }; })(); diff --git a/web/settings/tabs/planner.test.mjs b/web/settings/tabs/planner.test.mjs index d9861f22c..edbf40e57 100644 --- a/web/settings/tabs/planner.test.mjs +++ b/web/settings/tabs/planner.test.mjs @@ -10,8 +10,9 @@ import assert from "node:assert/strict"; globalThis.window = {}; await import("./planner.js"); +const { styleForK } = await import("../../plan-prefs.js"); const tab = globalThis.window.FTWSettings.tabs.planner; -const { strategyLabel, hedgeLine, engineSelect } = tab._pure; +const { strategyLabel, styleNote, formatK, engineSelect, marginModel } = tab._pure; describe("strategyLabel", () => { it("maps every planner mode via the local fallback", () => { @@ -45,11 +46,16 @@ describe("strategyLabel", () => { }); }); -describe("hedgeLine", () => { - it("does not infer the active margin from the legacy residual", () => { - assert.match(hedgeLine("1", 0), /margin varies by interval/); - assert.match(hedgeLine("1", 432), /Plan chart/); - assert.equal(hedgeLine("0", 432), "No forecast margin requested."); +describe("styleNote", () => { + it("names the Plan card style a fine-tuned margin belongs to", () => { + assert.equal(styleNote(0.3, { styleForK }), "Balanced. Changes apply at once."); + assert.equal(styleNote("0.15", { styleForK }), "Bold. Changes apply at once."); + assert.equal(styleNote(0.7, { styleForK }), "Between two styles, nearest Careful. Changes apply at once."); + }); + + it("stays plain without the Plan card's style table", () => { + assert.equal(styleNote(0.3, null), "Changes apply at once."); + assert.equal(formatK("0.30000000000000004"), "0.3"); }); }); @@ -68,10 +74,15 @@ describe("render", () => { assert.ok(!html.includes("planner.mode"), "planner.mode must not be bound in the form"); }); - it("renders the active-strategy placeholder and hedge line containers", () => { + it("renders the active-strategy placeholder and the live forecast margin above the engine details", () => { const html = tab.render(stubCtx()); assert.ok(html.includes('id="planner-active-strategy"')); - assert.ok(html.includes('id="planner-hedge-line"')); + const margin = html.indexOf('id="planner-style-k"'); + assert.ok(margin > 0 && margin < html.indexOf(" { @@ -97,14 +108,16 @@ describe("render", () => { 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]")); + assert.ok(!html.includes("config.yaml sets pv_forecast_safety_k")); }); - it("binds pv_forecast_safety_k inside engine details when YAML set it", () => { + it("says a YAML pv_forecast_safety_k only seeds the first start", () => { const ctx = stubCtx(); ctx.config.planner = { pv_forecast_safety_k: 0.25 }; const html = tab.render(ctx); const rest = html.slice(html.indexOf(" { @@ -163,3 +176,47 @@ describe("engine selection", () => { }); } }); + +describe("marginModel", () => { + const stored = (k) => { + const m = marginModel(); + m.endRead(m.beginRead(), k); + return m; + }; + + it("ignores a read that started before a confirmed write", () => { + const m = marginModel(); + const read = m.beginRead(); + m.request(0.6); + m.confirm(0.6); + m.settle(true); + m.endRead(read, 0.3); // the redraw's read answers late with the old value + assert.deepEqual(m.view(), { k: 0.6, state: "saved" }); + }); + + it("shows the newest value on its way until every save has answered", () => { + const m = stored(0.3); + m.request(0.8); + m.request(1); + assert.deepEqual(m.view(), { k: 1, state: "saving" }); + m.confirm(0.8); + m.settle(true); + assert.deepEqual(m.view(), { k: 1, state: "saving" }); + m.confirm(1); + m.settle(true); + assert.deepEqual(m.view(), { k: 1, state: "saved" }); + }); + + it("follows a style picked on the Plan card", () => { + const m = stored(0.3); + m.confirm(0.15); + assert.deepEqual(m.view(), { k: 0.15, state: "" }); + }); + + it("keeps the box's value after a failed save", () => { + const m = stored(0.3); + m.request(0.6); + m.settle(false); + assert.deepEqual(m.view(), { k: 0.3, state: "failed" }); + }); +});