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.
+
+ Keeps more in the battery
+ Counts more on the forecast
+
+
+
+
+
+
+ Safety limits are the same in every style.
+
+
+
diff --git a/web/plan-forecast.js b/web/plan-forecast.js
index f005ab079..f13c821b2 100644
--- a/web/plan-forecast.js
+++ b/web/plan-forecast.js
@@ -1,20 +1,26 @@
// The point forecast and the inputs after the margin belong to the same plan.
// Legacy PV-model residuals cannot describe the active forecast's margin.
-export function forecastMarginLine(actions, from, until) {
- let pv = 0, planningPV = 0, load = 0, planningLoad = 0, count = 0;
+//
+// forecastMargins sums the shown window: sun beyond the home's own use, as
+// forecast and as the plan counts on it, and the margin split into sun held
+// back and use added. null when the plan lacks either set of values.
+export function forecastMargins(actions, from, until) {
+ let sunBeyond = 0, plannedSunBeyond = 0, sunHeld = 0, useAdded = 0, count = 0;
for (const a of actions || []) {
const start = Math.max(a.slot_start_ms, a.execution_start_ms || 0, from);
const end = Math.min(a.slot_start_ms + a.slot_len_min * 60_000, until);
if (end <= start) continue;
if (![a.forecast_pv_w, a.forecast_load_w, a.pv_w, a.load_w].every(Number.isFinite)) return null;
const hours = (end - start) / 3_600_000;
- pv += Math.max(0, -a.forecast_pv_w) * hours / 1000;
- planningPV += Math.max(0, -a.pv_w) * hours / 1000;
- load += Math.max(0, a.forecast_load_w) * hours / 1000;
- planningLoad += Math.max(0, a.load_w) * hours / 1000;
+ const sun = Math.max(0, -a.forecast_pv_w), plannedSun = Math.max(0, -a.pv_w);
+ const use = Math.max(0, a.forecast_load_w), plannedUse = Math.max(0, a.load_w);
+ sunBeyond += Math.max(0, sun - use) * hours / 1000;
+ plannedSunBeyond += Math.max(0, plannedSun - plannedUse) * hours / 1000;
+ sunHeld += Math.max(0, sun - plannedSun) * hours / 1000;
+ useAdded += Math.max(0, plannedUse - use) * hours / 1000;
count++;
}
- return count ? `Shown intervals: PV forecast ${pv.toFixed(1)} kWh; plan uses ${planningPV.toFixed(1)} kWh. Load forecast ${load.toFixed(1)} kWh; plan uses ${planningLoad.toFixed(1)} kWh.` : null;
+ return count ? { sunBeyondKWh: sunBeyond, plannedSunBeyondKWh: plannedSunBeyond, sunHeldKWh: sunHeld, useAddedKWh: useAdded } : null;
}
export function evShortfallWh(plan) {
diff --git a/web/plan-forecast.test.mjs b/web/plan-forecast.test.mjs
index 43afec91a..15353506e 100644
--- a/web/plan-forecast.test.mjs
+++ b/web/plan-forecast.test.mjs
@@ -1,15 +1,31 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { forecastMarginLine, evShortfallWh } from "./plan-forecast.js";
+import { forecastMargins, evShortfallWh } from "./plan-forecast.js";
-test("forecast remains visible when the planning lower bound is zero", () => {
- const action = { slot_start_ms: 1000, slot_len_min:60, forecast_pv_w:-2314.6, forecast_load_w:1464.6, pv_w:0, load_w:3659.4 };
- assert.equal(forecastMarginLine([action],1000,3601000), "Shown intervals: PV forecast 2.3 kWh; plan uses 0.0 kWh. Load forecast 1.5 kWh; plan uses 3.7 kWh.");
- assert.equal(forecastMarginLine([{...action,forecast_pv_w:undefined}],1000,3601000),null);
- assert.match(forecastMarginLine([{...action,execution_start_ms:1801000}],1000,3601000),/PV forecast 1.2 kWh/);
+const round = (m) => Object.fromEntries(Object.entries(m).map(([k, v]) => [k, Math.round(v * 1e4) / 1e4]));
+
+test("margins keep the forecast visible when the planning lower bound is zero", () => {
+ const action = { slot_start_ms: 1000, slot_len_min: 60, forecast_pv_w: -2314.6, forecast_load_w: 1464.6, pv_w: 0, load_w: 3659.4 };
+ // Sun beyond use is 850 W for one hour; the plan counts on none of it.
+ assert.deepEqual(round(forecastMargins([action], 1000, 3601000)), {
+ sunBeyondKWh: 0.85, plannedSunBeyondKWh: 0, sunHeldKWh: 2.3146, useAddedKWh: 2.1948,
+ });
+ assert.equal(forecastMargins([{ ...action, forecast_pv_w: undefined }], 1000, 3601000), null);
+ // Only the part of the slot still to run counts.
+ assert.equal(round(forecastMargins([{ ...action, execution_start_ms: 1801000 }], 1000, 3601000)).sunHeldKWh, 1.1573);
+});
+
+test("sun beyond use is summed slot by slot, not from daily totals", () => {
+ const hour = (start, pv, load) => ({ slot_start_ms: start, slot_len_min: 60,
+ forecast_pv_w: pv, forecast_load_w: load, pv_w: pv, load_w: load });
+ // Noon has 3 kWh spare, the evening needs 2 kWh: 3 kWh can reach the battery.
+ const m = forecastMargins([hour(0, -4000, 1000), hour(3_600_000, 0, 2000)], 0, 7_200_000);
+ assert.equal(m.sunBeyondKWh, 3);
+ assert.equal(m.plannedSunBeyondKWh, 3);
+ assert.equal(m.sunHeldKWh, 0);
});
test("EV shortfall preserves an unmet goal instead of calling it complete", () => {
- assert.equal(evShortfallWh({loadpoint_shortfall_wh:{garage:2318,other:NaN}}),2318);
- assert.equal(evShortfallWh({}),0);
+ assert.equal(evShortfallWh({ loadpoint_shortfall_wh: { garage: 2318, other: NaN } }), 2318);
+ assert.equal(evShortfallWh({}), 0);
});
diff --git a/web/plan-prefs.js b/web/plan-prefs.js
index 36c3ba1c2..097782151 100644
--- a/web/plan-prefs.js
+++ b/web/plan-prefs.js
@@ -1,10 +1,11 @@
-// Household planner prefs for the Plan card: the safety-k slider,
-// battery-export permission, and the four export sentences.
+// Household planner prefs for the Plan card: the planning style, the
+// battery-export permission, and the sentences that explain both.
// Pure helpers — plan.js owns DOM and POST /api/planner/prefs.
//
// The stored primitive is safety_k, the share of each slot's own forecast
-// uncertainty the plan holds back. forecast_trust is the derived enum older
-// clients read; it never drives anything here.
+// uncertainty the plan holds back. The card offers five planning styles on
+// that one number; Settings → Planner fine-tunes it. forecast_trust is the
+// derived enum older clients read; it never drives anything here.
export const TRUST_STEPS = ["cautious", "balanced", "bold"];
@@ -12,18 +13,62 @@ export const SAFETY_K_MIN = 0;
export const SAFETY_K_MAX = 2;
export const SAFETY_K_STEP = 0.05;
+// Left to right, as on the card: keeps the most in the battery, then counts
+// more and more on the forecast. Balanced is the box default
+// (config.SafetyKDefault) and leans toward the forecast; keep the two equal.
+export const PLAN_STYLES = Object.freeze([
+ {
+ key: "very_careful",
+ name: "Very careful",
+ k: 1,
+ text: "Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery.",
+ },
+ {
+ key: "careful",
+ name: "Careful",
+ k: 0.6,
+ text: "Plans for less sun and more use than forecast.",
+ },
+ {
+ key: "balanced",
+ name: "Balanced",
+ k: 0.3,
+ text: "Plans for a little less sun and a little more use than forecast. A good start for most homes.",
+ },
+ {
+ key: "bold",
+ name: "Bold",
+ k: 0.15,
+ text: "Plans close to the forecast.",
+ },
+ {
+ key: "very_bold",
+ name: "Very bold",
+ k: 0,
+ text: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.",
+ },
+]);
+
+export const SAFETY_K_DEFAULT = 0.3;
+
const SALE_W = 100;
+// Below this a total reads as nothing on a 0.1 kWh display.
+const KWH_EPS = 0.05;
+// A slot counts as sunny when the forecast expects more than this.
+const SUNNY_W = 200;
+// Planned battery power below this is a discharge slot (mpc.IdleGateThresholdW).
+const DISCHARGE_W = 100;
// safetyK is the legacy enum→k mapping, kept for servers that answer with
-// forecast_trust and no safety_k.
+// forecast_trust and no safety_k. Mirrors config.ForecastTrust.SafetyK.
export function safetyK(trust) {
if (trust === "cautious") return 2;
if (trust === "bold") return 0;
- return 1;
+ return SAFETY_K_DEFAULT;
}
-// trustFromSafetyK mirrors config.TrustFromSafetyK so the label under the
-// slider matches the enum the box would report.
+// trustFromSafetyK mirrors config.TrustFromSafetyK so the enum the card
+// reports matches the one the box would report.
export function trustFromSafetyK(k) {
const n = clampSafetyK(k);
if (n <= 0.25) return "bold";
@@ -33,23 +78,95 @@ export function trustFromSafetyK(k) {
export function clampSafetyK(v) {
const n = typeof v === "number" ? v : parseFloat(v);
- if (isNaN(n)) return 1;
+ if (isNaN(n)) return SAFETY_K_DEFAULT;
if (n < SAFETY_K_MIN) return SAFETY_K_MIN;
if (n > SAFETY_K_MAX) return SAFETY_K_MAX;
return n;
}
-// formatSafetyK renders the slider's position at its own resolution: 0.05
-// steps need two decimals, and trailing zeros make the number look stuck.
+// formatSafetyK renders k at the fine-tune resolution: 0.05 steps need two
+// decimals, and trailing zeros make the number look stuck.
export function formatSafetyK(k) {
return String(Math.round(clampSafetyK(k) * 100) / 100);
}
-// A legacy model's residual does not measure the active plan's margin.
-export function hedgeLine(k) {
- return clampSafetyK(k) === 0
- ? "No forecast margin requested."
- : "The forecast margin varies by interval. This box has not supplied separate forecast and planning values.";
+// styleForK names the style a stored k belongs to. A k between two styles
+// (set in Settings) shows the nearest one, and exact says it is not that
+// style's own value.
+export function styleForK(k) {
+ const n = clampSafetyK(k);
+ let style = PLAN_STYLES[0];
+ for (const s of PLAN_STYLES) {
+ if (Math.abs(s.k - n) < Math.abs(style.k - n)) style = s;
+ }
+ return { style, exact: Math.abs(style.k - n) < 0.001 };
+}
+
+function kwh(v) {
+ return (Math.round(v * 10) / 10).toFixed(1) + " kWh";
+}
+
+// sunWindowLabel names the window the chart shows: the rest of today, today
+// and tomorrow, or tomorrow.
+export function sunWindowLabel(horizon, untilMs, tomorrowStartMs) {
+ if (horizon === "tomorrow") return "Tomorrow";
+ if (horizon === "today" || untilMs <= tomorrowStartMs) return "Rest of today";
+ return "Today and tomorrow";
+}
+
+// sunLine answers "why is the plan not filling the battery from the sun?"
+// with the two numbers that decide it.
+export function sunLine(margins, label) {
+ if (!margins) return null;
+ if (margins.sunBeyondKWh < KWH_EPS) {
+ return `${label}: the forecast expects no sun beyond what your home uses.`;
+ }
+ const head = `${label}: the forecast expects ${kwh(margins.sunBeyondKWh)} more sun than your home uses.`;
+ const counted = Math.min(margins.plannedSunBeyondKWh, margins.sunBeyondKWh);
+ if (counted >= margins.sunBeyondKWh - KWH_EPS) return `${head} The plan counts on all of it.`;
+ return `${head} The plan counts on ${kwh(counted)} of it.`;
+}
+
+// marginSplitLine is the expert view in Settings → Planner: the same margin
+// split into sun held back and use added.
+export function marginSplitLine(margins) {
+ if (!margins) return null;
+ if (margins.sunHeldKWh < KWH_EPS && margins.useAddedKWh < KWH_EPS) {
+ return "The current plan uses the forecast as it is.";
+ }
+ return `The current plan counts on ${kwh(margins.sunHeldKWh)} less sun and ${kwh(margins.useAddedKWh)} more use than forecast.`;
+}
+
+// extraSunLine says where sun beyond what the home and the plan need goes;
+// extra sun first covers planned use and imports. By dispatch's rule, live
+// surplus may charge the battery up to the operator's cap
+// (site.pv_surplus_absorb_soc_cap) when one is set, otherwise up to Core's
+// per-slot live_pv_surplus_soc_cap, and never during a discharge slot. Above
+// the slot's planned charge it is stored; otherwise it is exported. Silent
+// when the box does not send either cap, the window has no sunny slot, or
+// the plan caps the panels in a sunny slot: there extra sun may be held back
+// rather than exported, and the line cannot say which.
+export function extraSunLine(actions, from, until, operatorCap) {
+ if (!Number.isFinite(operatorCap)) return null;
+ let sunny = 0;
+ let stored = 0;
+ for (const a of actions || []) {
+ const start = Math.max(a.slot_start_ms, a.execution_start_ms || 0, from);
+ const end = Math.min(a.slot_start_ms + a.slot_len_min * 60_000, until);
+ if (end <= start) continue;
+ if (!Number.isFinite(a.live_pv_surplus_soc_cap)) return null;
+ const pv = Number.isFinite(a.forecast_pv_w) ? a.forecast_pv_w : a.pv_w;
+ if (!(Math.max(0, -pv) > SUNNY_W)) continue;
+ if (a.pv_curtail_active || Number(a.pv_limit_w) > 0) return null;
+ sunny++;
+ const cap = operatorCap > 0 ? operatorCap : a.live_pv_surplus_soc_cap;
+ const discharging = (Number(a.battery_w) || 0) < -DISCHARGE_W;
+ if (!discharging && cap > (Number(a.soc) || 0) + 0.005) stored++;
+ }
+ if (!sunny) return null;
+ if (stored === sunny) return "Sun beyond what your home and the plan need goes into the battery.";
+ if (stored === 0) return "Sun beyond what your home and the plan need goes to the grid.";
+ return "Sun beyond what your home and the plan need goes partly into the battery and partly to the grid.";
}
export function isBatterySale(action) {
@@ -104,6 +221,54 @@ export function exportSentence({
return "Battery sale blocked: permission is off or not checked.";
}
+// prefsQueue runs preference writes one after another, in the order they
+// were made, whichever control made them. A newer change to the same
+// preferences replaces one still waiting to be sent, and both callers get the
+// answer to the newer one, so the latest choice always wins. send(change)
+// performs one write and returns the box's answer; announce(answer, source)
+// runs after each write the box confirmed.
+export function prefsQueue(send, announce) {
+ let running = false;
+ const waiting = [];
+ const keys = (change) => Object.keys(change).sort().join();
+ function next() {
+ if (running || waiting.length === 0) return;
+ running = true;
+ const job = waiting.shift();
+ Promise.resolve()
+ .then(() => send(job.change))
+ .then((answer) => {
+ announce(answer, job.source);
+ job.callers.forEach((c) => c.resolve(answer));
+ }, (err) => job.callers.forEach((c) => c.reject(err)))
+ .then(() => {
+ running = false;
+ next();
+ });
+ }
+ return function save(change, source) {
+ return new Promise((resolve, reject) => {
+ const last = waiting[waiting.length - 1];
+ if (last && keys(last.change) === keys(change)) {
+ last.change = change;
+ last.source = source;
+ last.callers.push({ resolve, reject });
+ } else {
+ waiting.push({ change, source, callers: [{ resolve, reject }] });
+ }
+ next();
+ });
+ };
+}
+
+// prefsKnown is true once the box has said which margin it runs. Until then
+// the card checks no style rather than show a default as the box's choice.
+export function prefsKnown(status) {
+ const s = status || {};
+ return Number.isFinite(s.safety_k) || Number.isFinite(s.planner_mapped_k) ||
+ TRUST_STEPS.includes(s.forecast_trust);
+}
+
export function prefsFromStatus(status) {
const s = status || {};
const trust = TRUST_STEPS.includes(s.forecast_trust) ? s.forecast_trust : "balanced";
diff --git a/web/plan-prefs.test.mjs b/web/plan-prefs.test.mjs
index 3103a1f35..abf10f3e8 100644
--- a/web/plan-prefs.test.mjs
+++ b/web/plan-prefs.test.mjs
@@ -2,34 +2,43 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import {
+ PLAN_STYLES,
+ SAFETY_K_DEFAULT,
clampSafetyK,
formatSafetyK,
trustFromSafetyK,
safetyK,
- hedgeLine,
+ styleForK,
+ sunWindowLabel,
+ sunLine,
+ marginSplitLine,
+ extraSunLine,
+ prefsKnown,
isBatterySale,
exportSentence,
prefsFromStatus,
SAFETY_K_STEP,
+ prefsQueue,
} 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");
+const goPrefs = readFileSync(new URL("../go/internal/config/planner_prefs.go", import.meta.url), "utf8");
describe("safety k", () => {
- it("clamps to the slider's range and treats junk as balanced", () => {
+ it("clamps to the fine-tune range and treats junk as Balanced", () => {
assert.equal(clampSafetyK(0), 0);
assert.equal(clampSafetyK(0.85), 0.85);
assert.equal(clampSafetyK("0.85"), 0.85);
assert.equal(clampSafetyK(2), 2);
assert.equal(clampSafetyK(2.5), 2);
assert.equal(clampSafetyK(-1), 0);
- assert.equal(clampSafetyK("nope"), 1);
- assert.equal(clampSafetyK(undefined), 1);
+ assert.equal(clampSafetyK("nope"), SAFETY_K_DEFAULT);
+ assert.equal(clampSafetyK(undefined), SAFETY_K_DEFAULT);
});
- it("keeps every 0.05 step a distinct position", () => {
+ it("keeps every 0.05 fine-tune step distinct", () => {
assert.equal(SAFETY_K_STEP, 0.05);
const seen = new Set();
for (let i = 0; i <= 40; i++) seen.add(formatSafetyK(i * SAFETY_K_STEP));
@@ -47,18 +56,113 @@ describe("safety k", () => {
assert.equal(trustFromSafetyK(2), "cautious");
});
- it("maps the legacy enum onto PV safety k 2 / 1 / 0", () => {
+ it("maps the legacy enum onto k, with balanced as the Balanced style", () => {
assert.equal(safetyK("cautious"), 2);
- assert.equal(safetyK("balanced"), 1);
+ assert.equal(safetyK("balanced"), SAFETY_K_DEFAULT);
assert.equal(safetyK("bold"), 0);
});
});
-describe("hedge line", () => {
- it("does not turn a zero legacy residual into a no-margin claim", () => {
- assert.match(hedgeLine(1, 0), /varies by interval/);
- assert.doesNotMatch(hedgeLine(1, 0), /no hedge|0 W/);
- assert.match(hedgeLine(0, 432), /No forecast margin requested/);
+describe("planning styles", () => {
+ it("runs from keeping the most in the battery to counting most on the forecast", () => {
+ assert.deepEqual(PLAN_STYLES.map((s) => s.name), ["Very careful", "Careful", "Balanced", "Bold", "Very bold"]);
+ for (let i = 1; i < PLAN_STYLES.length; i++) assert.ok(PLAN_STYLES[i].k < PLAN_STYLES[i - 1].k);
+ assert.equal(PLAN_STYLES[PLAN_STYLES.length - 1].k, 0);
+ for (const s of PLAN_STYLES) assert.ok(s.k >= 0 && s.k <= 2 && s.text.endsWith("."));
+ });
+
+ it("puts Balanced in the middle, as the box default, leaning toward the forecast", () => {
+ const middle = PLAN_STYLES[2];
+ assert.equal(middle.key, "balanced");
+ assert.equal(middle.k, SAFETY_K_DEFAULT);
+ assert.ok(middle.k < 1, "the old default k=1 is now Very careful");
+ // The Go default is the same number.
+ assert.match(goPrefs, new RegExp(`SafetyKDefault = ${SAFETY_K_DEFAULT}\\b`));
+ });
+
+ it("names the style of a stored k, and says when it was fine-tuned", () => {
+ for (const s of PLAN_STYLES) assert.deepEqual(styleForK(s.k), { style: s, exact: true });
+ assert.equal(styleForK(0.15).style.name, "Bold");
+ assert.deepEqual(
+ [styleForK(0.7).style.name, styleForK(0.7).exact],
+ ["Careful", false],
+ );
+ assert.deepEqual([styleForK(2).style.name, styleForK(2).exact], ["Very careful", false]);
+ assert.equal(styleForK("junk").style.name, "Balanced");
+ });
+});
+
+describe("plan lines", () => {
+ it("names the window the chart shows", () => {
+ const tomorrow = 1_000_000;
+ assert.equal(sunWindowLabel("today", 2_000_000, tomorrow), "Rest of today");
+ assert.equal(sunWindowLabel("tomorrow", 2_000_000, tomorrow), "Tomorrow");
+ assert.equal(sunWindowLabel("all", 2_000_000, tomorrow), "Today and tomorrow");
+ // Before tomorrow's prices arrive the plan ends tonight.
+ assert.equal(sunWindowLabel("all", tomorrow, tomorrow), "Rest of today");
+ });
+
+ it("says how much of the spare sun the plan counts on", () => {
+ const m = (forecast, planned) => ({ sunBeyondKWh: forecast, plannedSunBeyondKWh: planned, sunHeldKWh: 0, useAddedKWh: 0 });
+ assert.equal(sunLine(m(12.44, 3.06), "Rest of today"),
+ "Rest of today: the forecast expects 12.4 kWh more sun than your home uses. The plan counts on 3.1 kWh of it.");
+ assert.equal(sunLine(m(6.4, 6.38), "Tomorrow"),
+ "Tomorrow: the forecast expects 6.4 kWh more sun than your home uses. The plan counts on all of it.");
+ assert.equal(sunLine(m(0.01, 0), "Rest of today"),
+ "Rest of today: the forecast expects no sun beyond what your home uses.");
+ assert.equal(sunLine(null, "Rest of today"), null);
+ });
+
+ it("splits the margin into sun and use for Settings", () => {
+ assert.equal(marginSplitLine({ sunHeldKWh: 0.9, useAddedKWh: 2.4 }),
+ "The current plan counts on 0.9 kWh less sun and 2.4 kWh more use than forecast.");
+ assert.equal(marginSplitLine({ sunHeldKWh: 0, useAddedKWh: 0.01 }), "The current plan uses the forecast as it is.");
+ assert.equal(marginSplitLine(null), null);
+ });
+
+ describe("extra sun", () => {
+ const slot = (start, pv, soc, cap, battery = 0) => ({ slot_start_ms: start, slot_len_min: 15,
+ forecast_pv_w: pv, pv_w: pv, soc, live_pv_surplus_soc_cap: cap, battery_w: battery });
+ const q = 15 * 60_000;
+ const stores = "Sun beyond what your home and the plan need goes into the battery.";
+ const exports = "Sun beyond what your home and the plan need goes to the grid.";
+
+ it("says nothing when the box does not send either cap", () => {
+ const actions = [slot(0, -3000, 0.4, 0.8)];
+ assert.equal(extraSunLine(actions, 0, q, undefined), null);
+ delete actions[0].live_pv_surplus_soc_cap;
+ assert.equal(extraSunLine(actions, 0, q, 0), null);
+ });
+
+ it("says nothing for a window without sun", () => {
+ assert.equal(extraSunLine([slot(0, 0, 0.4, 0.8)], 0, q, 0), null);
+ });
+
+ it("stores, sends to the grid, or splits, from Core's per-slot permission", () => {
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0.6)], 0, 2 * q, 0), stores);
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0), exports);
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0.8), slot(q, -2000, 0.4, 0)], 0, 2 * q, 0),
+ "Sun beyond what your home and the plan need goes partly into the battery and partly to the grid.");
+ });
+
+ it("needs room above the planned charge to call it stored", () => {
+ assert.equal(extraSunLine([slot(0, -3000, 0.8, 0.8)], 0, q, 0), exports);
+ });
+
+ it("follows the operator's cap ahead of the plan's, as dispatch does", () => {
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0)], 0, q, 0.88), stores);
+ assert.equal(extraSunLine([slot(0, -3000, 0.9, 0.95)], 0, q, 0.88), exports);
+ });
+
+ it("never stores during a planned discharge", () => {
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0, -2000)], 0, q, 0.88), exports);
+ });
+
+ it("says nothing where the plan caps the panels", () => {
+ const capped = { ...slot(q, -2000, 0.9, 0), pv_curtail_active: true, pv_limit_w: 500 };
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), capped], 0, 2 * q, 0), null);
+ assert.equal(extraSunLine([slot(0, -3000, 0.4, 0), { ...slot(q, -2000, 0.9, 0), pv_limit_w: 500 }], 0, 2 * q, 0), null);
+ });
});
});
@@ -114,15 +218,25 @@ describe("export sentences", () => {
});
});
+describe("prefsKnown", () => {
+ it("waits for the box to name its margin", () => {
+ assert.equal(prefsKnown({}), false);
+ assert.equal(prefsKnown(null), false);
+ assert.equal(prefsKnown({ safety_k: 0 }), true);
+ assert.equal(prefsKnown({ planner_mapped_k: 1 }), true);
+ assert.equal(prefsKnown({ forecast_trust: "bold" }), true);
+ });
+});
+
describe("prefsFromStatus", () => {
- it("defaults to balanced + unknown", () => {
+ it("defaults to Balanced + unknown", () => {
const p = prefsFromStatus({});
assert.equal(p.forecast_trust, "balanced");
assert.equal(p.battery_export, "unknown");
- assert.equal(p.safety_k, 1);
+ assert.equal(p.safety_k, SAFETY_K_DEFAULT);
});
- it("reads safety_k — the slider owns the number (#1017, #1020)", () => {
+ it("reads safety_k — the stored number owns the style (#1017, #1020)", () => {
const p = prefsFromStatus({
forecast_trust: "balanced",
battery_export: "allowed",
@@ -138,20 +252,32 @@ describe("prefsFromStatus", () => {
assert.equal(prefsFromStatus({ forecast_trust: "bold", planner_mapped_k: 0 }).safety_k, 0);
assert.equal(prefsFromStatus({ forecast_trust: "cautious" }).safety_k, 2);
assert.equal(prefsFromStatus({ forecast_trust: "bold" }).safety_k, 0);
+ assert.equal(prefsFromStatus({ forecast_trust: "balanced" }).safety_k, SAFETY_K_DEFAULT);
});
});
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/);
+ it("offers five planning styles in one row, none checked until the box answers", () => {
+ assert.match(html, /Planning style/);
+ const group = html.match(/
Load the plan prepares for${(loadW / 1000).toFixed(1)} kW
`);
}
const evWatts = plannedEVWatts(a);
if (evWatts > 0) {
@@ -1086,21 +1103,29 @@ import {
canvas.addEventListener('touchcancel', endTouch);
}
- // Household prefs (safety k + battery export) on the Plan card.
- // The slider POSTs safety_k only; export is sent unchanged so moving the
- // slider never turns on battery export.
- let trustDirty = false;
- let prefsPosting = false;
+ // Household prefs (planning style + battery export) on the Plan card.
+ // A style POSTs safety_k only; export is sent unchanged so picking a
+ // style never turns on battery export.
+ let pickedK = null; // picked here, not yet confirmed by the box
+ let prefsPosts = 0; // card writes the box has not answered
let replanPending = false;
+ let statusTimer = null;
+
+ // Settings → Planner fine-tunes the same number. It is a classic script,
+ // so it reads the style table and the margin sums from here.
+ window.FTWPlanPrefs = {
+ PLAN_STYLES, styleForK, formatSafetyK, clampSafetyK, marginSplitLine,
+ forecastMargins: (actions, from, until) => forecastMargins(actions, from, until),
+ savePlannerPrefs: (change, source) => savePlannerPrefs(change, source),
+ };
function currentPrefs() {
return state.prefs || prefsFromStatus(state.status);
}
- function sliderK() {
- const slider = document.getElementById("forecast-trust-slider");
- if (slider) return clampSafetyK(slider.value);
- return clampSafetyK(currentPrefs().safety_k);
+ // The k the card shows: a fresh pick wins until the box has stored it.
+ function shownK() {
+ return pickedK != null ? pickedK : clampSafetyK(currentPrefs().safety_k);
}
// The replan a prefs POST triggers takes a moment; without a marker the
@@ -1132,39 +1157,52 @@ import {
}
}
- function renderHedge(k) {
- const hedgeEl = document.getElementById("forecast-trust-hedge");
- if (!hedgeEl) return;
- const bounds = horizonBounds(state.horizon);
- const text = forecastMarginLine(state.plan?.actions, bounds.tMin, bounds.tMax)
- || hedgeLine(k);
- if (text == null) {
- hedgeEl.hidden = true;
- hedgeEl.textContent = "";
- } else {
- hedgeEl.hidden = false;
- hedgeEl.textContent = text;
- }
+ function showLine(id, text) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.hidden = !text;
+ el.textContent = text || "";
}
- function renderSliderValue(k) {
- const el = document.getElementById("forecast-trust-value");
- if (el) el.textContent = "k " + formatSafetyK(k);
+ function showStyleStatus(text) {
+ clearTimeout(statusTimer);
+ showLine("plan-style-status", text);
+ if (text) statusTimer = setTimeout(function () { showLine("plan-style-status", ""); }, 8000);
+ }
+
+ // One step is checked; a stored k between two steps checks the nearest
+ // and says it was fine-tuned. The two plan lines describe the window the
+ // chart shows.
+ function renderStyle() {
+ const root = document.getElementById("plan-style");
+ if (!root) return;
+ const known = pickedK != null || prefsKnown(state.status);
+ const k = shownK();
+ const { style, exact } = styleForK(k);
+ root.querySelectorAll("[data-style]").forEach(function (btn) {
+ const on = known && btn.dataset.style === style.key;
+ btn.setAttribute("aria-checked", on ? "true" : "false");
+ btn.tabIndex = on || !known ? 0 : -1;
+ });
+ showLine("plan-style-text", known ? style.text : "");
+ showLine("plan-style-fine", known && !exact ? "Fine-tuned in Settings: k " + formatSafetyK(k) + "." : "");
+ const bounds = horizonBounds(state.horizon);
+ const actions = (state.plan && state.plan.actions) || [];
+ const margins = forecastMargins(actions, bounds.tMin, bounds.tMax);
+ showLine("plan-style-sun", sunLine(margins, sunWindowLabel(state.horizon, bounds.tMax, localMidnight(1))));
+ // Where extra sun goes follows the household planner modes' dispatch
+ // rules; other modes keep their own, so say nothing there.
+ const mode = String((state.status && state.status.mode) || "");
+ const household = mode === "planner_arbitrage" || mode === "planner_passive_arbitrage";
+ showLine("plan-style-extra", household
+ ? extraSunLine(actions, bounds.tMin, bounds.tMax, state.status && state.status.pv_surplus_absorb_soc_cap)
+ : null);
+ root.classList.toggle("is-replanning", replanPending || pickedK != null);
}
function syncPrefsUI() {
+ renderStyle();
const p = currentPrefs();
- const slider = document.getElementById("forecast-trust-slider");
- if (slider && !trustDirty) {
- const k = clampSafetyK(p.safety_k);
- slider.value = String(k);
- slider.setAttribute("aria-valuenow", String(k));
- slider.setAttribute("aria-valuetext", "k " + formatSafetyK(k) + ", " + trustFromSafetyK(k));
- }
- const kNow = trustDirty ? sliderK() : clampSafetyK(p.safety_k);
- renderSliderValue(kNow);
- renderHedge(kNow);
-
const unknown = p.battery_export === "unknown";
const banner = document.getElementById("plan-export-banner");
const row = document.getElementById("plan-export-row");
@@ -1185,74 +1223,121 @@ import {
}
}
- async function postPlannerPrefs(k, exportPerm) {
- if (prefsPosting) return;
- prefsPosting = true;
+ // Every preference write on the page goes through one queue, from the card
+ // and from Settings, so the box applies them in the order they were made.
+ // Each change carries only what it changes; the box keeps the rest. A
+ // confirmed write is announced as "ftw-planner-prefs" with the box's answer.
+ const savePlannerPrefs = prefsQueue(async function (change) {
+ const r = await apiFetch("/api/planner/prefs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(change),
+ });
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ }, function (answer, source) {
+ window.dispatchEvent(new CustomEvent("ftw-planner-prefs", { detail: Object.assign({}, answer, { source }) }));
+ });
+
+ // postPlannerPrefs sends one change from the card and says whether the box
+ // took it.
+ async function postPlannerPrefs(change) {
+ prefsPosts++;
setReplanPending(true);
try {
- const r = await apiFetch("/api/planner/prefs", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- safety_k: clampSafetyK(k),
- battery_export: exportPerm,
- }),
- });
- if (!r.ok) throw new Error("HTTP " + r.status);
- const j = await r.json();
+ const j = await savePlannerPrefs(change, "card");
state.prefs = {
forecast_trust: j.forecast_trust,
battery_export: j.battery_export,
safety_k: clampSafetyK(typeof j.safety_k === "number" ? j.safety_k : j.mapped_k),
};
- trustDirty = false;
- syncPrefsUI();
- await fetchAll();
+ return true;
} catch (e) {
- trustDirty = false;
- syncPrefsUI();
+ return false;
} finally {
- prefsPosting = false;
- setReplanPending(false);
+ prefsPosts--;
+ if (prefsPosts === 0) setReplanPending(false);
}
}
+ // A pick goes straight into the page's write queue, so it keeps its place
+ // among choices made in Settings. Picks made while one is on its way
+ // collapse into one write, and the latest wins.
+ function pickStyle(key) {
+ const style = PLAN_STYLES.find(function (s) { return s.key === key; });
+ if (!style) return;
+ pickedK = style.k;
+ showStyleStatus("");
+ renderStyle();
+ sendPickedStyle(style.k);
+ }
+
+ async function sendPickedStyle(k) {
+ const ok = await postPlannerPrefs({ safety_k: clampSafetyK(k) });
+ if (pickedK === k) {
+ pickedK = null;
+ if (!ok) showStyleStatus("Not saved: the box did not answer. Try again.");
+ }
+ syncPrefsUI();
+ if (ok) await fetchAll();
+ }
+
+ async function setExport(exportPerm) {
+ const ok = await postPlannerPrefs({ battery_export: exportPerm });
+ syncPrefsUI();
+ if (ok) await fetchAll();
+ }
+
function initPrefs() {
- const slider = document.getElementById("forecast-trust-slider");
- if (slider) {
- slider.addEventListener("input", function () {
- if (slider.disabled) return;
- trustDirty = true;
- const k = clampSafetyK(slider.value);
- slider.setAttribute("aria-valuenow", String(k));
- slider.setAttribute("aria-valuetext", "k " + formatSafetyK(k) + ", " + trustFromSafetyK(k));
- renderSliderValue(k);
- renderHedge(k);
+ const steps = document.getElementById("plan-style-steps");
+ if (steps) {
+ steps.addEventListener("click", function (e) {
+ const btn = e.target.closest("[data-style]");
+ if (btn) pickStyle(btn.dataset.style);
});
- slider.addEventListener("change", function () {
- if (slider.disabled) return;
- const p = currentPrefs();
- postPlannerPrefs(clampSafetyK(slider.value), p.battery_export);
+ // Radio-group keys: arrows move from the focused style and pick, Home
+ // and End jump to an end. Before the box answers no style is checked,
+ // so the focused one is the only honest origin.
+ steps.addEventListener("keydown", function (e) {
+ const moves = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 };
+ const focused = e.target.closest("[data-style]");
+ let i = focused
+ ? PLAN_STYLES.findIndex(function (s) { return s.key === focused.dataset.style; })
+ : PLAN_STYLES.indexOf(styleForK(shownK()).style);
+ if (e.key in moves) i = Math.min(PLAN_STYLES.length - 1, Math.max(0, i + moves[e.key]));
+ else if (e.key === "Home") i = 0;
+ else if (e.key === "End") i = PLAN_STYLES.length - 1;
+ else return;
+ e.preventDefault();
+ pickStyle(PLAN_STYLES[i].key);
+ const btn = steps.querySelector('[data-style="' + PLAN_STYLES[i].key + '"]');
+ if (btn) btn.focus();
});
}
+ const settingsBtn = document.getElementById("plan-style-settings");
+ if (settingsBtn) {
+ settingsBtn.addEventListener("click", function () {
+ const settings = window.FTWSettings;
+ if (settings && typeof settings.open === "function") settings.open("planner");
+ });
+ }
+ // A fine-tune in Settings → Planner changes the same number.
+ window.addEventListener("ftw-planner-prefs", function (e) {
+ if (e.detail && e.detail.source !== "card") fetchAll();
+ });
const check = document.getElementById("plan-export-check");
if (check) {
check.addEventListener("change", function () {
- const p = currentPrefs();
- postPlannerPrefs(p.safety_k, check.checked ? "allowed" : "not_allowed");
+ setExport(check.checked ? "allowed" : "not_allowed");
});
}
const allow = document.getElementById("plan-export-allow");
if (allow) {
- allow.addEventListener("click", function () {
- postPlannerPrefs(currentPrefs().safety_k, "allowed");
- });
+ allow.addEventListener("click", function () { setExport("allowed"); });
}
const deny = document.getElementById("plan-export-deny");
if (deny) {
- deny.addEventListener("click", function () {
- postPlannerPrefs(currentPrefs().safety_k, "not_allowed");
- });
+ deny.addEventListener("click", function () { setExport("not_allowed"); });
}
}
diff --git a/web/settings.js b/web/settings.js
index 7deaefca8..e7b5aaee4 100644
--- a/web/settings.js
+++ b/web/settings.js
@@ -73,6 +73,16 @@
});
});
+ // Another card can open Settings on one tab, as the Plan card's
+ // "Fine-tune in Settings" does for Planner.
+ S.open = function (tab) {
+ if (tab && S.tabs[tab]) currentTab = tab;
+ tabsEl.querySelectorAll("button").forEach(function (button) {
+ button.classList.toggle("active", button.dataset.tab === currentTab);
+ });
+ openBtn.click();
+ };
+
function closeSettings() {
modal.classList.add("hidden");
if (returnFocus && returnFocus.isConnected && returnFocus.getClientRects().length) {
diff --git a/web/settings/tabs/planner.js b/web/settings/tabs/planner.js
index 62c354254..e3fd11c8c 100644
--- a/web/settings/tabs/planner.js
+++ b/web/settings/tabs/planner.js
@@ -36,12 +36,63 @@
return label;
}
- // The active forecast owns its interval margin; the legacy PV residual
- // cannot quantify it. The Plan chart shows the actual paired inputs.
- function hedgeLine(k) {
- return Number(k) === 0
- ? "No forecast margin requested."
- : "The margin varies by interval. See forecast and planning values on the Plan chart.";
+ function formatK(k) {
+ return String(Math.round(Number(k) * 100) / 100);
+ }
+
+ // styleNote names the Plan card style a k belongs to. The style table
+ // comes from plan.js (window.FTWPlanPrefs); without it the note is plain.
+ function styleNote(k, lib) {
+ if (!lib || typeof lib.styleForK !== "function") return "Changes apply at once.";
+ var found = lib.styleForK(k);
+ return (found.exact ? found.style.name + "." : "Between two styles, nearest " + found.style.name + ".") +
+ " Changes apply at once.";
+ }
+
+ // marginModel holds what the margin slider shows, for the whole page: the
+ // value the box last confirmed, from any control, unless a save from here
+ // is on its way. A read that started before a confirmed write is older
+ // than it and changes nothing.
+ function marginModel() {
+ var confirmed = null;
+ var rev = 0;
+ var pending = 0;
+ var requested = null;
+ var outcome = ""; // "saved" or "failed" after this page's last save
+ return {
+ confirm: function (k) { confirmed = k; rev++; outcome = ""; },
+ beginRead: function () { return rev; },
+ endRead: function (readRev, k) { if (readRev === rev && pending === 0) confirmed = k; },
+ request: function (k) { pending++; requested = k; outcome = ""; },
+ settle: function (ok) { pending--; if (pending === 0) outcome = ok ? "saved" : "failed"; },
+ view: function () {
+ if (pending > 0) return { k: requested, state: "saving" };
+ return { k: confirmed, state: outcome };
+ },
+ };
+ }
+
+ var margin = marginModel(); // outlives a redraw of the tab
+ var paintMargin = null; // the open tab's painter
+ var marginListening = false;
+
+ // savePlannerMargin sends only the margin, through the Plan card's queue so
+ // writes from the card and from here reach the box in the order they were
+ // made. The queue announces the box's answer; without the card, so does
+ // this.
+ function savePlannerMargin(apiFetch, k) {
+ var lib = window.FTWPlanPrefs;
+ if (lib && typeof lib.savePlannerPrefs === "function") return lib.savePlannerPrefs({ safety_k: k }, "settings");
+ return apiFetch("/api/planner/prefs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ safety_k: k }),
+ })
+ .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
+ .then(function (p) {
+ window.dispatchEvent(new CustomEvent("ftw-planner-prefs", { detail: Object.assign({}, p, { source: "settings" }) }));
+ return p;
+ });
}
function engineSelect(engine, help) {
@@ -70,15 +121,24 @@
}
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 owns it, anywhere from 0 to 2 in steps of 0.05.
' +
- '';
- }
+ // The live forecast margin. It saves at once through the same
+ // endpoint as the Plan card's styles, not through config Save.
+ var fineHtml = '
config.yaml sets pv_forecast_safety_k to ' +
+ (ctx.escHtml || String)(formatK(planner.pv_forecast_safety_k)) +
+ '. It only seeds the first start; the forecast margin above is what the planner uses.
'
+ : "";
return '
' +
+ 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" });
+ });
+});