Skip to content

feat(plan): five planning styles instead of the k slider - #1486

Merged
frahlg merged 8 commits into
masterfrom
1482-plan-styles
Oct 1, 2026
Merged

frahlg merged 8 commits into
masterfrom
1482-plan-styles

Conversation

@frahlg

@frahlg frahlg commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Problem

Refs #1482.

A beta tester asked why the plan would not put the day's solar surplus into the battery. The cause was the forecast margin (safety_k), but the Plan card made that hard to see:

  • It was titled "Follow the forecast", yet moving right followed the forecast less, and the number rose as trust fell.
  • "k 1" had no unit, and the card did not show the range (0–2).
  • "Shown intervals: … Load forecast 33.1 kWh; plan uses 35.5 kWh" read as if the plan would consume 35.5 kWh.
  • The chart showed the careful plan as if it were the expected outcome, with dashed and solid green lines that are hard to tell apart on a phone.
  • Nothing said whether sun beyond the plan would be stored or sold. The same tester later saw the battery idle at 38 % while 3.5 kW went to the grid.

Change

Plan card

  • Five planning styles on the same stored safety_k, laid out like a fund's risk scale: Very careful (k 1), Careful (0.6), Balanced (0.3), Bold (0.15), Very bold (0). One sentence per style. The word "risk" stays off the card, as the existing test requires: on an energy box it reads as danger.
  • One line for the window the chart shows: "Today and tomorrow: the forecast expects 18.0 kWh more sun than your home uses. The plan counts on 1.2 kWh of it." Sun beyond use is summed slot by slot, so evening use does not cancel noon surplus.
  • One line on extra sun, beyond what the home and the plan need, by dispatch's rule: the operator's pv_surplus_absorb_soc_cap when set, otherwise Core's per-slot live_pv_surplus_soc_cap (feat(plan): show when live solar may charge the battery #1485), and never during a discharge slot. It says stored, sent to the grid, or some of each. It shows only in the two household planner modes, only when the box sends both caps, and not where the plan caps the panels, so it never guesses.
  • "Safety limits are the same in every style." and a link that opens Settings → Planner.
  • No style is checked until the box has said which margin it runs. A stored k between two styles checks the nearest and says "Fine-tuned in Settings: k 0.45."
  • Picking a style waits 400 ms, so tapping across the scale sends one request. If the box does not answer, the card goes back to the stored style and says so.
  • Radio-group keys: arrows move from the focused style and pick, Home and End jump to an end.
  • A style pick sends only safety_k; the export switch sends only battery_export.

Chart: the margin is shaded between the forecast line and the value the plan counts on, for sun and for use. Tooltip rows read "PV the plan counts on" and "Load the plan prepares for".

Settings → Planner: a live "Forecast margin (k)" control, 0–2 in steps of 0.05, that saves at once with only safety_k, names the nearest style, and shows the margin as sun held back and use added. The margin line hides when there is no plan.

One write path on the page: every preference write, from the card and from Settings, goes through one queue (prefsQueue in web/plan-prefs.js), so the box applies them in the order people made them. Each confirmed write is announced as ftw-planner-prefs with the box's answer. The Settings slider paints one page model (marginModel): the value the box last confirmed from any control, unless a save from Settings is on its way; a read that started before a confirmed write changes nothing. The old YAML-bound field is gone. It only seeded the first boot and edited nothing after that; a YAML value now gets a one-line note saying so.

Core

  • SafetyKDefault 1 → 0.3, and the legacy enum balanced maps to it, so an old client asking for balanced gets the card's default.
  • POST /api/planner/prefs accepts safety_k alone or battery_export alone and keeps the other as the box holds it, under the same write lock as every preference write. Two clients changing different preferences no longer undo each other. A request with neither is still a 400.
  • /api/status reports pv_surplus_absorb_soc_cap (0 when unset).
  • Stored state: a box whose stored k is exactly the old default 1 moves to Balanced once at boot (MigrateOldDefaultSafetyK, marker key planner_planning_styles). k 1 was both the first-boot default and the old three-step "balanced", so a stored 1 means "the middle". Any other stored value stays, so a 0.15 is still Bold. A later choice of k 1 (Very careful) is never moved, and neither is a k a new box seeds from its settings.

Why Balanced is 0.3

The owner asked for a middle that leans toward the forecast, and for evidence on whether k 1 is too careful. I replayed the home box's recorded plans in closed loop: each step re-solves with the Core DP from that variant's own state of charge, dispatches the first slot by Core's planner_arbitrage energy-path rules (idle slots hold the battery still and may only absorb surprise export up to the capture cap, charge slots back off when the meter imports more than planned, cover-load slots chase grid 0), and is charged against measured PV, house load, the car, and published prices.

  • Data: 24 Sep–1 Oct 2026, 1,171 decisions, 165.7 h simulated, one house in SE4 (9.6 kWh battery, k 0.15 recorded throughout). Measurements before 21 September are not in the native install's history, so this is one week.
  • Margin: Core's cold-start form, which the box ran this week (checked against the live plan: load gains k × max(250 W, load × (0.35 + 0.05 × lead)); PV loses k × (PV − model lower bound), modelled as k × 0.5 × PV).
  • Cost: grid cost minus the energy left in the pack at the week's mean import price (281.8 öre/kWh), in SEK for the week (the week's bill is about 150 SEK).
k Style Cost vs k 0 Import in the dearest 25 % of slots …while the battery sat empty
0 Very bold 0.00 10.0 kWh 0
0.15 Bold +1.20 10.6 kWh 0
0.3 Balanced +1.68 11.0 kWh 0
0.6 Careful +2.75 12.1 kWh 0
1 Very careful (old default) +5.45 12.4 kWh 0
2 fine-tune only +11.51 13.4 kWh 0
— perfect forecast −33.44 2.1 kWh 0.2

Checks: with PV loss 0.7 × PV, k 0.3 is −0.17 and k 1 +8.14; with a 201-level DP grid, k 0.3 is +0.41 and k 1 +5.69. The simulation books 119.9 SEK for the recorded k where the meter booked 128.6 SEK; every variant shares that error, so differences below about 2 SEK are noise.

Reading: up to 0.3 costs the same within noise; above 0.5 the cost grows with k; k 1 cost 3.6–5.4 % more. No style, not even Very bold, bought at the dearest prices with an empty battery this week, so the extra caution had nothing to prevent. Once a box has a week of scored forecasts, Core sets the margin from the net forecast error instead. In this week's data that band comes out close to zero, because the forecasts already ran pessimistic, so k would change little there. The margin matters most for new installs, which is where k 1 hurt.

Robustness: the same sweep with corrected forecasts. This week's forecasts leaned pessimistic for a reason that is being fixed separately: the box's forecast models were reset by every update (night load came out about 2 kW too high). The forecast archive also holds the Energyplan load forecast, which was nearly unbiased that week (+38 W at night). Replaying with it instead:

k Box forecasts Corrected load forecast
0 151.8 135.6
0.15 151.7 131.1
0.3 (Balanced) 152.3 128.0
0.6 155.1 126.5
1 156.9 127.5

(SEK for the week, after valuing the energy left in the pack.) With unbiased forecasts a moderate margin pays: k 0.3 saves 7.6 SEK against k 0, and imports at the dearest prices with an empty battery fall from 1.2 to 0.4 kWh. Balanced at 0.3 costs nothing with today's forecasts and keeps most of that gain once they are fixed, so it is the right middle in both cases.

Limits: one house, one late-September week in which the forecasts leaned pessimistic. In a week where the sun disappoints, a careful style earns its keep, and Very careful stays one tap away.

Verification

  • npm test: 682 pass. New tests cover the style table and its Go default, nearest-style naming, the sun and extra-sun lines, the margin split, the legacy enum, and the markup.
  • go test ./internal/config/... ./internal/api/... ./cmd/ftw/... pass, with a new test for the one-time move. make verify passed on this commit.
  • Browser, against a local server that serves this branch's web/ with API answers captured read-only from the home box (style saves answered locally, never forwarded): desktop and 375 px, dark and light, all five styles, the sun line moving with the style, keyboard, a failed save, Settings fine-tune between two styles, and the Settings margin line following a save. The home box runs older Core, so the extra-sun line was checked with injected capture values.
  • Browser again after the review fixes, through a proxy that reads the home box and answers writes locally: arrow left from Bold sent {"safety_k": 0.3} only; three quick Settings changes (0.6, 0.8, 1) sent 0.6 then 1, each with only safety_k, and ended on "Saved. Very careful."; the Settings margin line hid when the plan went away; no script errors.
  • A human still needs to look at the rendered card on a real box, per AGENTS.md.

Review

Codex reviewed this PR on GitHub and four times locally. Findings fixed here: a k seeded from settings on a new box was moved to Balanced; Settings sent a stale export permission, and later a read-then-write race (now partial updates under the server's lock); overlapping and stale save replies, across tab redraws and between the card and Settings (now one page queue and one page model); the extra-sun line ignored the operator's cap, PV caps and planned imports; arrow keys started from Balanced before the box answered; a stale Settings margin line.

Browser checks with delayed answers: Settings follows a style picked on the card; a redraw during a slow save shows the value on its way and ends on "Saved."; a late read does not replace a confirmed value; Settings 0.8 and 1 followed by the card's Bold reach the box in that order, so Bold wins.

Related

The changeset is minor: a visible change of the default that the owner approves here.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T14:58:23.028080Z 2435fe5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2435fe5f9f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/cmd/ftw/main.go Outdated
Comment on lines +591 to +593
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, stylesDone); first {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve an explicitly configured k=1 on first startup

When SQLite has no planner preferences yet and config.yaml explicitly sets pv_forecast_safety_k: 1, ResolvePlannerPrefs correctly seeds safetyK to 1, but the absent styles marker makes this unconditional migration replace it with 0.3. Thus a new or reset installation silently runs a substantially less conservative margin than the operator configured; migration should distinguish a stored pre-styles default from a value just sourced from YAML.

AGENTS.md reference: AGENTS.md:L20-L21

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 7b39638: the one-time move to Balanced now applies only to a k already stored in SQLite. A k seeded from the settings document on a new box stays. Test case seeded from YAML in TestMigrateOldDefaultSafetyKMovesOnlyTheOldDefaultOnce.

Comment thread web/plan-prefs.js
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Expose the surplus cap before depending on it

For every real /api/mpc/plan response, this check returns null: the handler serializes mpc.Plan directly, and mpc.Action has no live_pv_surplus_soc_cap JSON field (the value currently exists only on runtime SlotDirective). Consequently plan-style-extra is always hidden, so the newly advertised explanation of whether extra sun is stored or exported never appears. Add the per-slot value to the plan API or derive the message from fields that the endpoint actually returns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

#1485 adds live_pv_surplus_soc_cap to mpc.Action, so the plan API returns it. #1485 merges before this PR. Without the field the line stays hidden, which is the intended fallback.

frahlg and others added 6 commits October 1, 2026 20:42
A beta tester asked why the plan would not put the day's solar surplus into
the battery. The answer was the forecast margin, but the Plan card showed it
as "Follow the forecast" with a raw k from 0 to 2 that rose as trust fell,
and a chart whose dashed and solid lines were hard to tell apart.

The card now offers five planning styles, Very careful to Very bold, on the
same stored safety_k. Under them, one line says how much of the spare sun
the plan counts on for the window the chart shows, and another says whether
extra sun goes into the battery or to the grid, from Core's per-slot
live_pv_surplus_soc_cap when the box sends it. The chart shades the margin.
Settings → Planner fine-tunes k and saves at once, and shows the margin as
sun held back and use added.

Balanced (k 0.3) is the new default. A closed-loop replay of a week of the
home box's recorded plans found k up to 0.3 cost the same within noise,
while k 1 cost 3.6–5.4 % more. A box that still runs the old default k 1
moves to Balanced once; any other stored value stays.

Refs #1482

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
On a new box, k can come from the settings seed this boot. That value
is the operator's choice, not the old default, so the one-time move to
Balanced now applies only to a k that was already stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Four findings from a local Codex review:

- Settings fine-tuning sent the export permission seen when the tab
  opened, so a margin change could turn battery sales back on after
  another client turned them off. Each save now reads the box's current
  choice first.
- Saves could overlap, and an older reply could reset the slider to an
  older value and call it saved. Saves now go one at a time, and a
  reply only updates the slider when no newer value waits and the
  slider still shows what was sent.
- The extra-sun line ignored the operator's surplus cap, which dispatch
  prefers to the plan's per-slot cap. /api/status now reports
  pv_surplus_absorb_soc_cap, and the line follows dispatch's rule,
  including that a discharge slot never stores.
- The Settings margin line kept old numbers when the plan went away.
  It now hides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Four findings from a second local Codex review:

- Reading the export choice before a margin save still left a race
  with another client. POST /api/planner/prefs now accepts safety_k
  alone or battery_export alone, and keeps the other under the same
  write lock, so two clients changing different preferences never undo
  each other. The Plan card and Settings send only what they change.
- Reopening the Planner tab started a second save queue that could
  race the first. Settings now keeps one queue for the page.
- Where the plan caps the panels (pv_curtail_active or pv_limit_w),
  extra sun may be held back rather than exported. The extra-sun line
  now says nothing for such windows.
- Before the box answered, arrow keys moved from Balanced rather than
  the focused style. They now move from the focused style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Three findings from a third local Codex review:

- The Plan card and Settings had separate save queues, so two writes
  could reach the box out of order. All preference writes on the page
  now go through one queue (prefsQueue), and each confirmed write is
  announced with the box's answer. Settings follows a style picked on
  the card.
- Reopening Settings while a margin save was on its way showed the old
  stored value and then ignored the reply. The pending value now
  survives the redraw, and the reply updates the open tab unless a drag
  is under way.
- In a slot that imports, extra sun lowers the import before anything
  is exported. The line now speaks only of sun beyond what the home
  and the plan need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg and others added 2 commits October 1, 2026 20:53
Two findings from a fourth local Codex review, both from Settings
keeping its own save queue and reads on top of the page's queue:

- A pending Settings value could reach the box after a newer style
  picked on the Plan card. Each released slider value now goes straight
  into the page's queue, so writes reach the box in the order they
  were made.
- A read started before a confirmed write could replace it. The slider
  now paints one page model (marginModel): the value the box last
  confirmed, from any control, unless a save from here is on its way.
  A read older than a confirmed write changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Two findings from a fifth local Codex review:

- A style picked while another card write was on its way waited
  outside the page queue, so a later Settings choice could be sent
  first and then lose to the older pick. Card picks now go straight
  into the queue, and the queue lets a newer change to the same
  preference replace one still waiting; both callers get the newer
  answer. This also replaces the card's 400 ms delay.
- Chrome sends no change when a drag ends where it began, so the
  slider stayed in drag mode and hid a failed save. A drag now ends
  when the pointer is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg merged commit bf85123 into master Oct 1, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant