Skip to content

feat(planner): persist forecast trust and battery export permission - #968

Merged
frahlg merged 3 commits into
masterfrom
feat/planner-forecast-trust
Aug 29, 2026
Merged

frahlg merged 3 commits into
masterfrom
feat/planner-forecast-trust

Conversation

@frahlg

@frahlg frahlg commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Status

This PR is PR 1 of the household-planner beta. Core prefs are in: SQLite + /api/planner/prefs + status fields. No UI yet.

A cloud agent should continue with PR 2–4 on this branch (or stacked on it). Do not put planning docs in the repo.

Planning docs stay out of git. The full plan is in this description.

What this commit does

  • forecast_trust: cautious | balanced | bold → only pv_forecast_safety_k (2 / 1 / 0)
  • battery_export: unknown | not_allowed | allowed → existing planner_passive_arbitrage / planner_arbitrage
  • Balanced = today's default (k=1)
  • Unknown export never sells from the battery
  • Upgrade from Active: unknown, forced to passive until confirmed
  • YAML pv_forecast_safety_k wins (yaml_custom)
  • GET/POST /api/planner/prefs; same fields on /api/status
  • HA and app mode setters confirm export the same way as /api/mode

Remaining work (do this next)

PR 2 — Plan card: slider + export

  • Remove Passive/Active as primary strategy buttons in web/app.js
  • web/plan.js: slider “Follow the forecast” (Hold reserve → Trust forecast), export permission, four sentences, upgrade banner
  • Live hedge line under the slider (k·σ in watts)
  • Browser check desktop + narrow

PR 3 — Settings: simple on top, engine below

  • web/settings/tabs/planner.js: top = enabled, house reserve, soc_max
  • Closed <details> at the bottom: “Engine controls — leave these unless you are debugging.”
  • Saving with the disclosure closed must not wipe engine fields
  • Do not put engine=python or CLARABEL on top. HiGHS is implied.

PR 4 — Weather: no array onboarding

  • Map stays. No +Add on the normal path
  • Copy: production pattern from measured solar, not “orientation”
  • Existing arrays: read-only “N arrays set in config”
  • Editor only inside closed advanced if needed

PR 5 (optional, do not block beta)

  • Twins + one-liner on the Plan card from existing Quality() / Samples
  • Rename reset to “Solar system changed”

Product rules (do not break)

  • Do not call the slider risk
  • Slider never turns on battery export
  • Do not map slider onto CVaR, scenario trees, or Python-only knobs
  • Do not rewrite control/dispatch.go
  • Do not retier ModeCatalog this beta (app/HA keep Passive/Active keys)
  • Do not write planning docs into the repo
  • Do not auto-write observed peak AC into pv_rated_w

Mapping

battery_export
  unknown      → planner_passive_arbitrage
  not_allowed  → planner_passive_arbitrage
  allowed      → planner_arbitrage

forecast_trust  (only if YAML did not set pv_forecast_safety_k)
  cautious  → k = 2.0
  balanced  → k = 1.0
  bold      → k = 0.0

Copy

Slider: “Follow the forecast”. Left “Hold reserve”. Right “Trust forecast”.
Help: “Left keeps more in the battery if the sun might miss — closer to using the battery only for the house. Right follows the forecast fully. If the forecast is right, right earns more.”

Export: “Allow the battery to sell to the grid when the plan expects a worthwhile sale. Solar can still export when this is off. Check your electricity contract.”
Unknown: “Not checked — battery export stays off.”

Upgrade banner: “FTW used to sell from the battery on high-price hours. Allow that to continue?”

Plan sentences:

  • “Battery sale planned HH:MM–HH:MM.”
  • “Solar export only; the battery is not selling.”
  • “Battery export is allowed, but FTW found no worthwhile sale.”
  • “Battery sale blocked: permission is off or not checked.”

Test plan

  • go test ./internal/config ./internal/api ./internal/mpc ./cmd/ftw
  • make verify on commit
  • Browser: Plan card slider + export (PR 2)
  • Settings advanced disclosure closed does not wipe YAML (PR 3)

Note

Medium Risk
Changes which planner mode runs (battery grid export) and MPC PV hedging on boot, API, HA, and UI; includes a migration that forces passive arbitrage until export is confirmed.

Overview
Adds household planner preferences — follow-the-forecast (cautious / balanced / bold) and battery export permission (unknown / not_allowed / allowed) — persisted in SQLite with YAML first-boot defaults, exposed on GET/POST /api/planner/prefs and /api/status, and wired through app link, HA, and /api/mode so export permission stays in sync with planner modes.

Forecast trust maps to PV downside safety k (2 / 1 / 0) via EffectiveSafetyK, with explicit YAML pv_forecast_safety_k still winning; changing trust triggers mpc.SetSafetyK and a replan. Export permission maps to planner_passive_arbitrage vs planner_arbitrage; unknown never sells from the battery. Upgrades from active arbitrage without stored export get unknown and are downgraded to passive until the user confirms.

Plan card UI replaces Passive/Active as primary strategy controls with the forecast slider, export checkbox/upgrade banner, live k·σ hedge line, and plan export sentences; renderModeCatalog skips planner_* keys (HA/app catalogs unchanged). Settings puts house reserve and enable on top and hides engine/optimizer knobs behind a closed Engine controls disclosure; weather keeps the map on the normal path and moves array geometry editing into advanced copy focused on measured solar.

Reviewed by Cursor Bugbot for commit 9205e02. Bugbot is set up for automated code reviews on this repo. Configure here.

Household planner prefs live in SQLite like mode: forecast_trust
(cautious/balanced/bold) maps only to pv_forecast_safety_k, and
battery_export (unknown/not_allowed/allowed) maps onto the existing
passive vs active planner modes.

Balanced is today's k=1. Unknown export never sells from the battery.
An upgrade from Active arbitrage starts unknown and is forced to
passive until confirmed. YAML pv_forecast_safety_k still wins.

GET/POST /api/planner/prefs plus the same fields on /api/status.
HA and the app mode setters confirm export the same way as /api/mode.
Comment thread go/internal/api/api_planner_prefs.go
Comment thread go/internal/config/planner_prefs.go

@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: 53e8777ff1

ℹ️ 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 on lines +77 to +81
if err := s.deps.State.SaveConfig(config.StateKeyForecastTrust, string(trust)); err != nil {
return err
}
if err := s.deps.State.SaveConfig(config.StateKeyBatteryExport, string(export)); err != nil {
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Downgrade before returning on persistence failure

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

Useful? React with 👍 / 👎.

Comment on lines +658 to +660
s.mu.Lock()
s.PVForecastSafetyK = k
request := s.beginReplanLocked(ctx, "safety_k_changed")

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 Snapshot the safety factor under the planner mutex

When a scheduled or otherwise active replan overlaps a preference POST, this assignment is protected by s.mu, but runReplan later reads s.PVForecastSafetyK without that lock at service.go:1202. The new runtime setter therefore introduces a Go data race; include the factor in replanRequest while holding the mutex, as is already done for Defaults, so each generation uses one synchronized value.

AGENTS.md reference: AGENTS.md:L55-L57

Useful? React with 👍 / 👎.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale comment

Left a non-blocking comment: Cursor Bugbot reported 2 unresolved findings, including a high-severity planner-mode remap, so this is not approved. Assigned erikarenhill and HuggeK for human review.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested review from Leitet and erikarenhill August 21, 2026 14:17
Replace Passive/Active as primary strategy buttons with follow-the-forecast
and a battery-export permission. Settings keep house reserve on top and hide
optimizer knobs behind a closed disclosure. Weather no longer asks for array
orientation on the normal path.
Comment thread web/app.js
Comment thread web/plan.js

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale comment

Left a non-blocking comment: Cursor Bugbot skipped on this head and still has 2 unresolved findings that need human review, so this is not approved. Reviewers are already assigned.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Household planner prefs: forecast trust (cautious/balanced/bold → PV safety k) and a battery-export permission (unknown/not_allowed/allowed), replacing the implicit Passive/Active split as the source of truth for whether the battery may sell.

Correctness — the safety-relevant bit is the migration: any site that was on planner_arbitrage before this ships has no battery_export row in SQLite yet, so ResolvePlannerPrefs/DeriveBatteryExport maps it to unknown, and main.go's boot check (ctrl.Mode == ModePlannerArbitrage && export != Allowed) force-switches it to passive until the household explicitly confirms via app/API/HA. Traced this through and it holds — a household that already has allowed stored is untouched, only the never-confirmed ones get paused. Good test coverage on the resolution matrix (TestResolvePlannerPrefsActiveUpgradeAsks etc.) and the API tier wiring (POST /api/planner/prefs is Actuate, matching /api/mode).

Minor, non-blocking — applyPlannerPrefs recreates s.deps.PlannerPrefs if it's nil with no lock around the field write; two concurrent POST /api/planner/prefs on a nil-prefs server could race. Looks dead in practice since main.go always constructs Deps with a non-nil PlannerPrefs, so flagging for awareness rather than asking for a change.

CI green across the board. Safe to merge from my read.

@frahlg
frahlg merged commit c1bf48c into master Aug 29, 2026
1 check passed

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9205e02. Configure here.

if mm, ok := control.PlannerMPCMode(mapped); ok && s.deps.MPC != nil {
s.deps.MPC.SetMode(ctx, mm)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prefs save always reapplies mode

High Severity

applyPlannerPrefs always calls ApplyMode whenever the box is already in a planner mode, including when the mapped mode is unchanged. ApplyMode clears a battery manual hold and resets the PI on every Plan-card save, so moving only the forecast slider can drop a hold and glitch dispatch. The same path also remaps planner_self and planner_cheap onto planner_passive_arbitrage or planner_arbitrage.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9205e02. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Left a non-blocking comment: Cursor Bugbot skipped on this head, so this is not approved. Reviewers are already assigned; none were added.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

frahlg added a commit that referenced this pull request Aug 30, 2026
…seed (#1018)

Owner report (#1017): the Plan card's forecast-trust slider was
permanently disabled on any site with an explicit pv_forecast_safety_k
in config.yaml, with a note telling the operator to go edit the file —
a config file acting as a user interface. The reporter's box had
pv_forecast_safety_k: 1, the same value as "balanced", so the lock
bought nothing at all.

The field now follows the same stored-wins contract forecast_trust got
in #968: on a first boot with nothing stored it seeds the nearest
trust step (k ≤ 0.25 → bold, < 1.5 → balanced, else cautious) and is
never consulted again — EffectiveSafetyK is the trust mapping,
YAMLCustomK and the dead PVSafetyK helper are gone, the API stops
reporting yaml_custom/planner_yaml_custom, and the UI loses the
disable + the "config.yaml wins" note.

Closes #1017.


Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants