Skip to content

fix(mpc): show when forecast caution is off, and fix the #1644 review findings - #1663

Merged
frahlg merged 7 commits into
masterfrom
1644-caution-followups
Oct 16, 2026
Merged

frahlg merged 7 commits into
masterfrom
1644-caution-followups

Conversation

@frahlg

@frahlg frahlg commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Follow-ups to #1644, from the review. Fredrik decided that publishing the point-forecast plan when forecast caution fails is fine, but only with a visible warning. This PR adds that warning, makes it show only for real fallbacks, and fixes what the review found.

Visible warning: caution_off

When the planning style asks for caution (safety_k not 0) and the plan uses the point forecast instead, the published plan says so:

  • mpc.Plan.CautionOff, sent as caution_off in GET /api/mpc/plan and in the plan diagnostic.
  • cautionOff in the app's plan message (protocol.Plan). The plan feed from feat(appproto): push the plan to streaming phones when the box replans #1643 pushes it to phones when it changes, because each change comes with a new plan. An older app ignores it.
  • The box page shows a yellow Caution off badge on the Plan tab and the overview card, and "Point forecast / No margin for forecast error in this plan" in the forecast panel. The warning that says why stays in the constraint line. I checked both views in a browser against fixture data, not against a box.
  • Core logs one line when caution goes off and one when it comes back.

It is set when the caution inputs are invalid, when Core has to drop the caution forecast as invalid, when a cautious plan breaks the point plan's limits or departure goals, when a load prior replaces the forecast on a planner that solves caution, and when the point plan covers only part of the horizon. The next plan that keeps its caution clears it. A safety_k of 0 ("bold") never reads as caution off.

Registry: no change. Plan fields (stale, ceilingW, chargers, socPermille) live in protocol/messages.go and messages.ts, not in registry.yaml, and cautionOff follows them.

Why cautious plans were refused with a car

Before this PR, Energyplan's cautious plan was refused in almost every replan with a car:

  • battery_covers_ev off: battery discharge feeds loadpoint car
  • battery_covers_ev on (Fredrik's site): loadpoint car charges during battery-driven export

The cautious forecast adds house load, and the plan discharges the battery to cover it while the car charges. validateMarginPointLoad replays the plan with the point load and the same battery power, so the energy the house no longer uses either feeds the car or exports. The vehicle rules then refused the plan, the point plan was published, and the badge would have shown on most car evenings.

Neither refusal protects anything at dispatch:

  • With battery_covers_ev off, both dispatch paths stop discharge at the house while a car charges (control/dispatch.go: the energy path's uncoveredEVW clamp and the legacy path's gridW -= uncoveredEVChargingW()). Load that does not show up cannot make the battery feed the car.
  • With it on, the owner lets the battery feed the car. The battery energy the house does not use is export, and the mode decides whether that is allowed.

So the replay now checks only what invented load could really buy: battery export the mode forbids (modeAllows) and grid limits. The vehicle rules still apply in full to the cautious plan against its own inputs, and to every other plan. ValidatePlan is unchanged; the replay calls validatePlan(…, vehicleRules: false). TestForecastMarginCannotGrantExtraDischargePermission now covers both settings: a protected car and a covered car with export allowed are accepted, and self-consumption or passive-arbitrage export is still refused.

S4: slot 0 was tagged as Core's recovery

Confirmed first: TestForecastArchiveKeepsModelSourceUnderLiveLoad runs a real replan with the forecast tracker, varying loads and a live meter. On master it fails: slot 0 attributed to recovery: source="core_recovery" quality="fallback" model=<nil>.

Fix: runReplan passes the archive an explicit mask of the loads it replaced (ForecastInputs.Record(base, planning, recoveredLoad, …)). The mask compares the published point loads with the selected forecast after the live overlay, so the meter's in-flight load is not a recovery. Core never recovers PV, so the PV branch is gone.

S5: replan time with caution on and off

BenchmarkNativeCautionReplan runs Service.Replan end to end with the bundled Energyplan worker (ftw-solver-darwin-arm64, production budgets) and with the Core planner. Fixed instances: 10 kWh battery, 11 kW car from 40 % to 80 % in 7 hours, 17.25 kW fuse, 15-minute prices, PV, caution margin; a morning and an evening plug-in. Median of 5 replans on an Apple M5, ms to a published plan, with the car:

Plug-in, car rule, horizon k 0 k 0.3 before this PR k 0.3 now
morning, battery may not feed car, 35 h 1 503 3 005 1 503
morning, battery may not feed car, 48 h 1 053 ¹ 10 009 3 220
evening, battery may not feed car, 35 h 1 503 3 005, caution off 5 of 5 1 503
evening, battery may not feed car, 48 h 5 004 10 007, caution off 5 of 5 5 004
morning, battery covers car, 35 h 1 503 3 005, caution off 3 of 3 ² 1 407
morning, battery covers car, 48 h 5 004 — ³ 5 004
evening, battery covers car, 35 h 54 — ³ 57
evening, battery covers car, 48 h 5 005 — ³ 99

After this PR no cautious plan was refused in any case (0 of 5 each).

¹ The worker stops before its budget once it proves a plan optimal, so a row can vary between runs; earlier runs gave 5 004 here. The final run started with a load average of 15 on 10 cores from another session.
² Measured with caution first but the old replay.
³ Not measured before this PR. There every k 0.3 replan with a car solved the point plan and then the cautious one.

With a car, Energyplan spends its whole budget on each solve (1.5 s, or 5 s at 193 slots × 5 assets), so caution doubled the time to a plan. That time is set by the budget, not the CPU, so the N150 and the Pi 4 should see the same seconds. I did not run on them; I was asked not to touch the home box during this work. Battery-only solves take 2–4 ms, and Core plans take under 3 ms with or without caution.

Change: when there is time for both passes (12 s left in the slot and in the caller's deadline), the caution plan is solved first, with a budget that leaves the point plan a full 5 s. A full cautious plan that misses no departure leaves nothing for a point plan to improve on, because preservesDepartureGoals accepts it against any point plan, 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 goes first, and caution gets only the time left after it. The worker keeps no state between requests, so the order does not change what it finds. A refused cautious plan still costs two solves.

I chose this over publishing the point plan first and then the cautious one, because that would show phones a point plan for a few seconds on every replan, need a second decision ID and archive record per replan, and need a second publish path in runReplan.

From the review of this PR

  • The caution budget is taken after the point solve, so it ends inside the slot. TestForecastCautionBudgetEndsInsideTheSlot fails without this: after a 300 ms point solve, caution could run 300 ms past the slot.
  • Caution first: budget = min(budget, available − the 5 s fleet budget).
  • A shortened point plan sets withoutCaution, in both orders.
  • After a prior recovery, only a planner that solves the caution forecast (downsidePrimary) reads as caution off.
  • A repeated failure compares stage and message with the digits stripped, so a moving slot number or watt figure is still the same failure.
  • The changeset no longer says "halves": an accepted cautious plan takes one solve, a refused one still takes two.

Earlier review notes

  • corePlanWithForecastRecovery returns (forecastChoice, error), so the error is last.
  • A failure that repeats keeps its first record, so a lasting caution fault no longer causes a FULL-sync SQLite write on every replan. Evidence restored from before a restart is replaced once. A hard failure that comes back after a recovered plan has its recovered marker cleared.
  • sanitizeSlotLoads had no caller outside its test and is removed. The Energyplan budget comment describes the two requests.
  • The risk-load recovery warning said "using legacy forecasts or the configured load prior"; it uses the point loads and now says so.

Also found

Six worker tests never ran in CI: make native-solver-test selects -run 'Native|RustForecastHost', and their names had no "Native". Five came with #1644 (four TestForecastMargin… tests and TestEVGoalPlanAndPriceContract). They are renamed TestNative… and pass with the bundled worker.

Tests

  • go test -p 2 for ./internal/mpc (also with FTW_NATIVE_SOLVER set), ./cmd/ftw, ./internal/control, ./internal/appproto, ./internal/api, ./protocol/...
  • node --test web/plan-brief.test.mjs
  • make verify
  • New: TestForecastArchiveKeepsModelSourceUnderLiveLoad, TestRecordMarksOnlyRecoveredLoads, TestCautionOffIsPublishedAndClears, TestRepeatedPlanningFailureIsWrittenOnce, TestRepeatedFailureIgnoresChangingNumbers, TestForecastCautionSolvesFirstWhenBothPassesFit, TestForecastCautionBudgetEndsInsideTheSlot, TestShortenedPointPlanIsWithoutCaution, TestPriorRecoveryMarksCautionOffOnlyForDownsidePlanners, TestPlanSaysWhenForecastCautionIsOff, clearing on the Energyplan path in TestNativeForecastMarginKeepsPointAfterOptionalFailure, a caution_off case in the plan brief tests, and the benchmark. The budget, shortened-plan and prior-recovery tests each fail with their fix removed.

Follow-ups, not in this PR

  • Show cautionOff in ftw-webapp and ftw-app, and decide whether it belongs in events.get (review item 8).
  • FTW Cloud's readable plan class does not carry it.
  • Not run on the home box, the N150 or a Pi 4.

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 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-16T08:23:19.031449Z 52f420e 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: 52f420ebc1

ℹ️ 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/internal/mpc/failure_diagnostic.go Outdated
Comment on lines +147 to +149
func (s *Service) repeatsPlanningFailureLocked(stage, message string) bool {
f := s.lastPlanningFailure
return f != nil && !s.planningFailureRestored && f.Stage == stage && f.Error == message

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 Retain new inputs when deduplicating failures

When consecutive replans fail with the same stage and message but different prices, forecasts, goals, or optimizer payloads, this predicate suppresses the newer record solely because the strings match. PlanningFailureSnapshot and the persisted evidence then continue exposing the first attempt's generation, timestamp, and inputs—even after a successful replan occurred between failures—so operators investigate stale evidence rather than the current failure. Deduplicate only when the captured inputs/payload are also unchanged, or update the in-memory latest record while separately avoiding redundant storage writes.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

frahlg and others added 6 commits October 16, 2026 10:53
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>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@frahlg
frahlg force-pushed the 1644-caution-followups branch from 52f420e to 0a00b75 Compare October 16, 2026 09:12
@frahlg
frahlg merged commit d9d7b0a into master Oct 16, 2026
18 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