Skip to content

fix(mpc): keep valid plans when forecast caution fails - #1644

Merged
frahlg merged 5 commits into
masterfrom
fix/reserve-plan-prices
Oct 16, 2026
Merged

frahlg merged 5 commits into
masterfrom
fix/reserve-plan-prices

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Optional forecast caution could disable price planning even when the point forecast had a valid full plan. It could also return a feasible plan that needlessly missed a car's departure goal, so retrying only infeasible replies was not enough.

Core now solves and validates the point forecast first. It retains that plan while trying caution within the remaining solve and publication time. The optional candidate must retain the whole horizon and may not worsen the departure energy deficit of any car. Core also replays the same device actions with point household load and downside PV, so extra forecast load cannot grant permission for battery export or battery-fed EV charging. Timeouts, malformed results, invalid caution inputs and bad uncertainty metadata leave the point plan available. Rejected inputs remain in durable support diagnostics.

If the point forecast itself is infeasible, the same optimizer can retry with the configured household-load prior under a bounded budget. A qualified current measurement stays fixed. Invalid model load uses an independent numeric forecast, rather than becoming zero. Core fallback tries the point forecast before a degraded prior; a prior cannot replace a complete point plan.

Core's EV reserve can also move the same delivered energy to cheaper legal slots. It retains the battery trajectory and checks instantaneous charger pulses. Energy-only repricing stays disabled when demand tariffs are present, since a lower energy bill alone does not prove a lower total bill. This reserve improvement does not claim a global optimum.

Validation includes real native-worker calls and independent Wh checks for reachable and genuinely unreachable EV goals, remaining first-slot time, optional infeasibility, avoidable shortfall, invalid current/future risk PV, bad uncertainty metadata, broken replies, cancellation, two-car goal tradeoffs and discharge permissions. The deadline regression first reproduced a risk pass consuming the usable point plan's publication time, then passed with an advancing injected clock. make verify and whole MPC, forecast and Core entrypoint race suites passed on the final master-based candidate c70ff0b2, using the bundled native worker from private source 38c4872. All applicable GitHub CI checks passed, including Linux ARM64/AMD64 builds and the full stack. The independent reviewer reproduced both deadline and invalid-metadata failures before the fixes, then passed its own 24 metadata cases plus physical rejection checks with no remaining findings.

This PR targets master after #1643 merged. The separate worker headroom fix is in private srcfl/energyplan#77; merged #1651 carries its compiled bundle.

A future policy should use household uncertainty to value a bounded, soft battery-energy reserve, with EV deadline energy protected and price still able to outweigh costly extra reserve. Scheduled EV charging remains separate from unknown household demand. A later strategy change can value extra reserve differently while preserving the same EV delivery priority. Those policy and client changes remain proposals; this PR adds the immediate availability guard.

@frahlg
frahlg marked this pull request as ready for review October 15, 2026 17:50
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 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-15T17:53:00.296510Z c14cb97 Draft marked ready
ℹ️ 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: c14cb97b98

ℹ️ 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 +57 to +59
offCost := SlotGridCostOre(slot, baseGrid*hours/1000, p)
onCost := SlotGridCostOre(slot, (baseGrid+peak)*hours/1000, p)
offers = append(offers, offer{i, peak, (onCost - offCost) / (peak * hours * eff)})

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 Account for demand tariffs when ranking reserve slots

When p.DemandCharges is populated, this ranks offers solely by the marginal energy cost returned by SlotGridCostOre, which does not include demand charges. During an optimizer fallback, the repricer can therefore move EV charging into a slightly cheaper interval that establishes a much more expensive monthly demand peak, and the later TotalCostOre comparison will still accept it for the same reason. Either include the applicable demand-charge marginal cost in this ranking or leave the original reserve unchanged when demand charges are active.

Useful? React with 👍 / 👎.

@frahlg
frahlg force-pushed the fix/reserve-plan-prices branch from 02c69c6 to 5a51c57 Compare October 15, 2026 18:38
@frahlg frahlg changed the title fix(mpc): keep price planning after forecast failures fix(mpc): keep valid plans when forecast caution fails Oct 15, 2026
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg changed the base branch from claude/plan-push to master October 16, 2026 04:21
@frahlg
frahlg force-pushed the fix/reserve-plan-prices branch from 5a51c57 to c70ff0b Compare October 16, 2026 04:21
@frahlg
frahlg merged commit 69c4f31 into master Oct 16, 2026
18 checks passed
@frahlg

frahlg commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

A read-only review of this PR (head 5a51c57) from the FTW plan-push work, passed on with Fredrik's OK (16 October 2026). It found no new path that publishes an unvalidated or stale plan: every candidate still goes through ValidatePlan on the slots it was solved for, and the dispatch gates and stale-meter handling are untouched.

Fredrik's decision: when the caution inputs are invalid or the caution pass is infeasible, publishing the point-forecast plan instead of failing the replan is fine, but the warning must be visible, as an event or status flag the apps can show, not only a string in planningWarnings.

Should-fix

  • S4. Slot 0 is tagged as recovery on every replan with a valid meter. recoveredForecastSources (cmd/ftw/forecast_tracking.go:726-742, called at :686) compares base (baseForecastSlots, which carries the live-load overlay, service.go:1598, copied at :1731) with selected (from Resolve, before the overlay, service.go:1577). Slot 0's load differs on every replan with a valid meter, so it gets LoadSource="core_recovery", quality fallback and ModelLoad=nil, which corrupts model attribution in the forecast archive. The new test sets every load to 800 and cannot see it. Fix: skip the live-overlaid slot, or pass an explicit "recovered" mask from runReplan, and test with varying loads.
  • S5. Replan time roughly doubles with caution on, and the PR does not measure it. The point plan is solved first and publication waits for the caution solve (service.go:1909-1919 into forecast_margin.go:36-53), with Energyplan budgets of 1.5 s for EV and 5 s for the fleet per solve under a 12 s cap. Balanced (k 0.3) is the default style, so every replan pays this, which adds seconds to the plug-in-to-plan time that feat(appproto): push the plan to streaming phones when the box replans #1643 just cut. Please measure replan wall time on the N150 and a Pi 4. Consider publishing the point plan first and refining it after.

Notes

  • sanitizeSlotLoads is now unused outside tests, and the comment at energyplan.go:46 ("Core already adjusts PV…") is out of date. corePlanWithForecastRecovery returns its error before the last value.
  • Each recovered caution failure sets lastPlanningFailure, and persistPlanningFailure writes it with a FULL-sync SQLite commit, so a persistent NaN uncertainty gives one such write per replan. Record it only when the stage or message changes.
  • feat(appproto): push the plan to streaming phones when the box replans #1643 has merged (6a40212), so this PR needs a retarget to master and a rebase; service.go will conflict.

🤖 Generated with Claude Code

frahlg added a commit that referenced this pull request Oct 16, 2026
When the planning style asks for caution and the plan uses the point
forecast instead, the published plan now carries caution_off (HTTP and
diagnostic) and cautionOff (the app's plan message), set again on every
replan and cleared once caution works. A safety_k of 0 never reads as
off. Each change is logged once.

Also from the #1644 review:
- corePlanWithForecastRecovery returns its error last
- a planning failure that repeats keeps its first record, so a lasting
  caution fault no longer costs a FULL-sync write per replan; a hard
  failure that returns after a recovered plan is current again
- sanitizeSlotLoads had no caller outside its test
- the Energyplan budget comment described the old single PV margin
- worker tests from #1644 are renamed so make native-solver-test runs them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
… findings (#1663)

* fix(forecast): credit the live in-flight load to its model, not recovery

runReplan now tells the forecast archive which published loads Core
replaced. The archive used to infer it by comparing the published loads
with the selected forecast, which did not yet carry the live meter's
in-flight load, so slot 0 was tagged core_recovery on every replan with
a valid meter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mpc): say on the plan when forecast caution is off

When the planning style asks for caution and the plan uses the point
forecast instead, the published plan now carries caution_off (HTTP and
diagnostic) and cautionOff (the app's plan message), set again on every
replan and cleared once caution works. A safety_k of 0 never reads as
off. Each change is logged once.

Also from the #1644 review:
- corePlanWithForecastRecovery returns its error last
- a planning failure that repeats keeps its first record, so a lasting
  caution fault no longer costs a FULL-sync write per replan; a hard
  failure that returns after a recovered plan is current again
- sanitizeSlotLoads had no caller outside its test
- the Energyplan budget comment described the old single PV margin
- worker tests from #1644 are renamed so make native-solver-test runs them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web): show Caution off on the plan brief

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(mpc): solve forecast caution first when both passes fit

With a car plugged in, Energyplan spends its whole budget on each solve,
so caution doubled replan time: 1.5 s to 3.0 s for a 35-hour horizon and
5 s to 10 s for 48 hours. When time remains for both passes, the caution
plan is now solved first. A full cautious plan that misses no departure
leaves nothing for a point plan to improve on, so it is published after
one solve. Otherwise the point plan is solved and compared as before.
Near the end of a slot the point plan still goes first.

BenchmarkNativeCautionReplan measures it on the bundled worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): pass the recovered-load mask to Record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mpc): keep cautious plans a car-plus-battery site can run

The point-load replay refused nearly every cautious plan with a car:
the plan discharges the battery for the extra house load while the car
charges, and with that load gone the discharge fed the car or exported.
Dispatch already stops discharge at the house when battery_covers_ev is
off, and with it on the owner lets the battery feed the car. The replay
now judges only the mode's export rule and grid limits; the vehicle
rules stay on the cautious plan's own inputs.

From the review of #1663:
- the caution budget is taken after the point solve, so it ends in the slot
- caution first leaves the point plan a full fleet budget
- a shortened point plan reads as caution off
- only a downside planner loses caution to a prior
- a repeated failure compares its message without digits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.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.

1 participant