Skip to content

fix(mpc): the plan keeps each clock hour under the import ceiling - #1835

Merged
frahlg merged 6 commits into
masterfrom
fix/planner-hourly-import-limit
Oct 25, 2026
Merged

frahlg merged 6 commits into
masterfrom
fix/planner-hourly-import-limit

Conversation

@frahlg

@frahlg frahlg commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Problem

The planner never saw the owner's hourly import ceiling. PeakImportCeilingW had no use in go/internal/mpc; each slot's max_import_w came only from the fuse less the phase imbalance.

Field case from a home box on 25 October 2026 (v0.155.1-beta.1): ceiling 4.5 kW, house about 0.8 kW, a car that wanted 19 kWh more by 13:00. The plan ran the car at 4.83 kW from 09:00, imported 4.5–5.7 kWh an hour and showed the goal as met. Core held each hour to 4.5 kWh, so the goal was out of reach.

Change

One definition, from Core's live import hour (control/import_hour.go):

  • control.PlanningImportCeiling turns Core's import-hour state into the planner's input: the clock hour in progress (local time, as the live limit counts it), what it has used (imported Wh plus unrecorded time at the ceiling; before a reading measures the hour, the time so far at the ceiling), and the mean a planned hour may hold.
  • mpc.importHours lists every clock hour the plan overlaps, from the first slot's execution start, by chaining Core's hour start. The 23- and 25-hour days need no rule. On 25 October 2026 each of the two 02:00 hours counts on its own.
  • ValidatePlan holds every plan to the hours: the plan's import in an hour may reach the mean over the hour less what the hour had used. In an hour where every car is off it may instead reach what the house alone imports, since Core cannot cut the house; a car gets no such floor, because Core's live limit stops the car first. It counts each charging pulse's import while the pulse runs.
  • The Go DP and the EV reserve plan spread each hour over its slots. Each slot keeps its house import, and the rest is shared evenly, never above the hour's mean, so Core's live limit lets it through. When the hour holds the car back, the reserve plan's battery covers the house, never into export.
  • Energyplan gets the hours as hourly_import_caps (start_ms, end_ms, max_mean_w, elapsed_import_wh) only when the worker announces that feature, and only hours its slots overlap; only then does the request get the fleet budget (5 s). The field is agreed with the Energyplan side, srcfl/energyplan#78; the worker pin is chore(optimizer): bundle Energyplan 0.6.0 #1858 (Energyplan 0.6.0, which announces hourly_import_caps). This PR and chore(optimizer): bundle Energyplan 0.6.0 #1858 land together. docs/energyplan-contract.md is FTW master plus that PR's edits, to be byte-identical with ci: run go vet + test on PRs + master pushes #78's copy (sha256 178401b2…, the shared rule worded as above). Shipping needs an Energyplan release bundle with the feature in optimizer/native/bundle; the bundled 0.5.2 refuses the field, so Core sends it only to a worker that announces it. The bundled worker does not have the feature: Core checks its plan like any other, and when the plan overruns an hour Core publishes its own plan, marked as a fallback with the reason. When Core cannot plan the site itself (a battery outside its band, or a fleet its DP cannot model), it publishes the worker's plan as master does, with a warning; Core's live limit still holds each hour.
  • A new ceiling replans at once (import_ceiling_changed).
  • Ceiling off: no hours, so planner input, request bytes and plans stay as on master. No new setting. Core's live limit does not change.

What a planned hour may hold

The plan gets the ceiling less half of Core's rise step (1 A × voltage × phases, so 4155 W for 4.5 kW on 3 × 230 V). The car runs in whole amps, and the live limit raises it only with a step in hand, so the hours end 0.4–0.6 kWh under the ceiling. Closed-loop sitesim, the field case, master with #1827 and #1828, Core replanning every quarter (TestSiteSimPlanUnderImportCeiling):

planned hour battery covers the house: promised / delivered battery empty: promised / delivered
the ceiling 16.2 / 14.9 kWh (−8 %) 13.3 / 11.3 kWh (−15 %)
ceiling − 1 step 13.7 / 13.8 kWh 10.8 / 7.0 kWh (−35 %)
ceiling − ½ step (this PR) 15.0 / 14.9 kWh (−0.6 %) 12.1 / 11.3 kWh (−6 %)

A full step leaves too little in each quarter: the car spends the quarter's energy, stops, and waits for the hour's slack to start again. At half a step the car gets as much as at the ceiling, and the promise holds. The same test passes on the 25-hour day (−0.6 %, five local hours, each under the ceiling) and with a Core restart at 09:40 (−3.6 %). A sweep of 3.5, 6 and 7.5 kW stayed within 2 % with the battery's help. Without it, the gap was 12 % at 3.5 kW, the tightest case.

Why not max_import_w

max_import_w is an instant limit: Core's validation and the worker check the car's pulse peak against it. The ceiling caps an hourly mean. With 0.8 kW of house and a 4.14 kW minimum charge, an instant cap of 4.5 kW forbids the car whenever the battery cannot cover the house. The plan's 0 W would then keep Core from charging at all, though Core delivers about 3.1 kWh an hour to the car there.

Evidence

Failing first: on master the new tests do not compile, since no input carries the ceiling, and TestPeakImportCeilingChangeReplans fails (replan reason = ""). The subtest "without the ceiling the plan overruns the hours" pins master's plan for the field case: 6.2 / 6.3 / 6.3 / 5.4 kWh in the four hours, goal met. On this branch the same plan imports 4.15 kWh an hour, and the goal shows 4 kWh short.

TestNativeImportCeilingFieldCase (bundled worker): the worker's plan overruns the 09:00 hour. Core rejects it and publishes its own plan, which passes the same checks.

TestNativeEnergyplanTakesHourlyImportCaps runs the field case through EnergyplanOptimizer with a worker that announces hourly_import_caps: Core sends the hours and publishes the worker's own plan, which keeps every hour. It skips on the bundled 0.5.2 and turns on with the worker pin; it passes today against the worker built from srcfl/energyplan#78.

Other tests: DST in mpc and control, a restart with and without a seeded hour, a new ceiling mid-hour, hours around the plan, the spread, validation (budget, house floor, pulse import), the DP under a budget, the wire with and without the feature (and unchanged with the ceiling off), and the replan on a new ceiling.

Rebased on master 286ac14 (with #1828, #1849, #1850, #1852, #1853). Runs on this branch: go vet ./... clean. go test ./internal/... ./cmd/ftw/... passes. FTW_NATIVE_SOLVER=… go test ./internal/mpc -run Native passes. TestSiteSimFieldMatrix (342 cases) passes, and sitesim_field_known.txt does not change. The matrix plans with a stand-in, not mpc.

Review

An independent review (Sonnet) found, and this PR fixes, each with a test that fails without the fix:

  • No plan at all when the worker's plan overran an hour and Core could not plan the site itself (battery at 5 %, bundled worker). Now the worker's plan is published with a warning: TestImportCeilingWorkerPlanOverrunsAnHour, TestNativeImportCeilingWhenCoreCannotPlan.
  • The reserve plan could pick a step that needs the battery to discharge past the house, then run it part-time under the hour, so the battery drove export beside the car and the plan failed its own rule: TestReservePlanUnderAnHourNeverChargesTheCarFromExport.
  • The 5 s budget went to every worker while the ceiling was on, and hours past the plan's slots could go on the wire: TestEnergyplanRequestCarriesHourlyImportCaps.

After the rebase, the worker from #78 showed one more: Core's DP plans on a SoC grid, and replayed from the real SoC the battery can run short of the discharge that kept an hour under its budget (field case, 12:45: 4211 of 4155 Wh). The car now gives way in the replay, as Core's live limit cuts the car first: TestOptimizeCarGivesWayWhenTheBatteryRunsShort.

The Energyplan side found one in the call timing: when the first slot's end clips the worker's budget, the budget equalled Core's call deadline, so a worker that spent all of it answered too late. The budget now ends 5 % of the time to the deadline, at least 30 ms, before it: TestEnergyplanTimeBudgetRespectsRemainingSlot.

The review checked that 400 random sites plan hash-identical to master with the ceiling off, and about 8,000 random runs with it on found no other validation failure.

Limits and follow-ups

  • A forecast spike above the instant import limit while an hour binds: the battery would have to charge ahead of the spike, the hour does not let it, and the plan comes out shorter. It takes house load above the fuse less the phase imbalance.

  • The half step also holds battery-only grid charging 345 W under the ceiling, although the live limit lets a battery reach it.

  • Without chore(optimizer): bundle Energyplan 0.6.0 #1858 (bundled 0.5.2), Core calls the worker on each replan and rejects its plan whenever the ceiling binds. The solve time is spent and the plan shows as a fallback.

  • Battery empty, tight ceiling: the plan still promises 6–12 % more than the car gets, because Core restarts the car each quarter on the hour's slack. That is Core's live start rule, not the planner.

  • A car on solar surplus with no import in an hour the house alone overruns: Core's check allows it, the worker does not plan it, and Core's own planners do not either.

  • An even spread gives up back-loading: Core lets a later quarter use slack, the plan does not.

  • The reserve plan keeps the car's pulse at the highest legal step. Core's live caps hold the order to the hour's room, so the energy is what counts.

🤖 Generated with Claude Code

@frahlg
frahlg marked this pull request as ready for review October 25, 2026 11:10
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 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-25T11:12:28.970571Z 625245f 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.

frahlg and others added 4 commits October 25, 2026 18:58
The planner saw only the fuse. Under a 4.5 kW hourly import ceiling it
planned the car at 4.83 kW from 09:00, 5.5 kWh an hour, and called the
13:00 goal met while Core held each hour to 4.5 kWh.

control.PlanningImportCeiling turns Core's live import hour into the
planner's input: the clock hour in progress, what it has used, and the
mean a planned hour may hold, the ceiling less half a rise step. The
car runs in whole amps and a rise keeps a step in hand, so hours end
0.4-0.6 kWh under the ceiling; closed-loop in sitesim the car then gets
within 1-6 % of the promise. The planner lists each clock hour the plan
spans. ValidatePlan holds every plan to them, the house's own import
never failing an hour. The Go DP and the reserve plan spread each hour
over its slots, and the battery covers the house so the car gets the
hour's room. Energyplan gets the hours as hourly_import_caps when it
announces the feature; otherwise a worker plan that overruns an hour
falls back to Core's plan. A new ceiling replans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the first commit:

- A worker plan that broke only an hour left no plan when Core cannot
  plan the site itself (battery outside its band, a fleet the DP cannot
  model). The hour check now runs last and carries the plan; the service
  publishes it with a warning, as before the hours, and the live limit
  still holds each hour.
- The reserve plan could run a step part-time that needs the battery to
  discharge past the house, so the battery drove export beside the car
  and the plan failed its own rule. It takes a smaller step.
- Only a worker that takes the hours gets them, only those its slots
  overlap, and only then the fleet budget.
- docs/energyplan-contract.md follows srcfl/energyplan#78 on top of
  FTW's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed with srcfl/energyplan#78: in an hour where a car charges, the
plan's import keeps to what the hour has left; the house-alone floor
holds only while every car is off. With the floor, the reserve plan ran
the car on the import the battery freed by covering the house, in an
hour Core's live limit would stop the car. ValidatePlan, the DP, the
reserve plan and its pricing follow the rule; the contract doc states it
as one rule for Core and the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The DP plans on a SoC grid. Replayed from the real SoC, the battery can
run short of the discharge that kept an hour under its budget; in the
25 October field case the 12:00 hour came to 4211 of 4155 Wh. The car
now gives way in the replay, to the largest step that fits or none, as
Core's live limit cuts the car first.

A native test runs the field case through EnergyplanOptimizer with a
worker that takes hourly_import_caps; it skips on the bundled 0.5.2 and
passes against the worker built from srcfl/energyplan#78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the fix/planner-hourly-import-limit branch from dd02cb2 to 0b50769 Compare October 25, 2026 18:04
frahlg and others added 2 commits October 25, 2026 19:16
When the first slot's end clipped the Energyplan budget, the budget was
the call's deadline itself, so a worker that spent all of it answered
after Core had given up. The budget now ends 5 % of the time to the
deadline, at least 30 ms, before it. The fail-fast at 500 ms left keeps
the budget at 420 ms or more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit b0ce810 into master Oct 25, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 25, 2026
Bundle the worker from srcfl/energyplan e975bb9 (0.6.0, release
v0.6.0-beta.1) for linux-arm64, linux-amd64 and darwin-arm64. The worker
lists hourly_import_caps and keeps every clock hour under the owner's
hourly import limit; with #1835 Core sends it the hours. Without
caps its replies are those of 0.5.2. Licenses, notices and forecast
schemas are unchanged. Protocol v1 is unchanged.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.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