Repository navigation
fix(mpc): the plan keeps each clock hour under the import ceiling - #1835
Merged
Merged
Conversation
frahlg
marked this pull request as ready for review
October 25, 2026 11:10
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
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
force-pushed
the
fix/planner-hourly-import-limit
branch
from
October 25, 2026 18:04
dd02cb2 to
0b50769
Compare
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The planner never saw the owner's hourly import ceiling.
PeakImportCeilingWhad no use ingo/internal/mpc; each slot'smax_import_wcame 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.PlanningImportCeilingturns 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.importHourslists 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.ValidatePlanholds 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.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 announceshourly_import_caps). This PR and chore(optimizer): bundle Energyplan 0.6.0 #1858 land together.docs/energyplan-contract.mdis 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 inoptimizer/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.import_ceiling_changed).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):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_wmax_import_wis 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
TestPeakImportCeilingChangeReplansfails (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.TestNativeEnergyplanTakesHourlyImportCapsruns the field case throughEnergyplanOptimizerwith a worker that announceshourly_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
mpcandcontrol, 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 Nativepasses.TestSiteSimFieldMatrix(342 cases) passes, andsitesim_field_known.txtdoes not change. The matrix plans with a stand-in, notmpc.Review
An independent review (Sonnet) found, and this PR fixes, each with a test that fails without the fix:
TestImportCeilingWorkerPlanOverrunsAnHour,TestNativeImportCeilingWhenCoreCannotPlan.TestReservePlanUnderAnHourNeverChargesTheCarFromExport.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