Skip to content

fix(control): an idle car starts on the hour's slack under the import ceiling - #1828

Merged
frahlg merged 1 commit into
masterfrom
fix/import-ceiling-idle-start
Oct 25, 2026
Merged

frahlg merged 1 commit into
masterfrom
fix/import-ceiling-idle-start

Conversation

@frahlg

@frahlg frahlg commented Oct 25, 2026

Copy link
Copy Markdown
Member

Problem and result

A beta user's help report (25 October 2026) has the hourly import ceiling on: peak_import_ceiling_w 4,500 W, hourly-average mode. The car's minimum is 4,140 W and the house used about 0.85–1.3 kW. For a whole hour the car stayed at 0 W (fuse_cap_w=0, reason import_limit), although the hour had 3.3–3.9 kWh of room left.

The ceiling caps the clock hour's mean import, but an idle car could start only if house load plus its minimum fit under the ceiling at that moment. Here 0.85–1.3 kW + 4.14 kW is above 4.5 kW, so the car never started. A car that was already charging would have been allowed to keep going under the hour's rate and rise rules.

Now the idle car starts once the hour has saved enough room. It charges to the end of the hour, the next hour starts again at the ceiling and stops it, and every hour's mean stays under the ceiling. Owner request: "fix all bugs".

Cause. go/internal/control/ev_fuse_budget.go:575 (master) set r.startLevelW = math.Min(riseW, s.PeakImportCeilingW). stepEVIdleStart (:532–536) needs startLevelW − dwell's mean import − pending ≥ minimum, so for a start the hourly average worked as an instant cap. The rule came with #1699 ("on the hour's slack alone it would start at about :35 and stop at :00, once an hour"), and TestImportCeilingNoStartOnSlackAlone pinned it.

Change (ev_fuse_budget.go:573–579):

  • The start level is now the rise level, riseW. The rise level already follows the hour's rules (stepHourImportLimits, import_hour.go:149–188). It starts each hour at the ceiling, rises only in whole steps with one step in hand, waits 5 minutes after a cut and never rises in the hour's last 15 minutes. The rate cut still guards the hour's energy.
  • In the hour's last 15 minutes (importHourRiseMinLeft) the start level stays at most the ceiling, as before. A start on slack then would run only minutes before the next hour stops it. A car that fits under the ceiling itself still starts then.
  • Two comments now describe the new rule. No other code changes.

Scope and safety

Limits

  • When house load plus the car's minimum is above the ceiling, the charger starts and stops about once an hour. It starts around :17–:32 in the tests and stops at :00. That wears the contactor, and some cars dislike pauses. It is the price of using the hour's room.
  • The rise rule is cautious. In the help-report case each hour ends about 0.5 kW under the ceiling (means 3,948–4,024 W against 4,500 W).
  • The planner does not see the ceiling yet. Its plan may still expect charging the ceiling does not allow, or the reverse. That is a separate follow-up.
  • After the stop at :00, a fuse still warm from a run near its limit can make the idle car say fuse_limit for about 6 s. The label names whichever room is smaller, and the fuse's heat model briefly leaves less. TestImportCeilingNightNeverSaysFuse checks the label from a minute after the stop.
  • web/app.js:3001 says "this hour's import budget is used" for an idle import_limit. That is now also shown while the car waits for the hour to save room. The wording is not changed here, because a UI change needs a browser review. Follow-up.
  • The site simulation's wantsCharge (sitesim_test.go:400–405) expects a start once house + minimum fits the hour's rate. Core's rise level trails that rate by one or two steps. No current row combines a ceiling with house + minimum above it, so nothing fails. A future row would need that slack.
  • No hardware run. Everything below is simulated.

Verification

go/internal/control/import_hour_test.go, the hourSite harness: an Easee-like 3-phase charger (5 s command delay, 10 s late telemetry), a 25 A site, a 1 s site meter, 4,140 W minimum.

Case master this PR
4.5 kW ceiling, 850 W house, 09:00–12:00 car 0 Wh; hour means 850 W starts 09:17:31, 10:17:00, 11:17:00; stops only at :00; about 9.5 kWh to the car; hour means 3,993 / 4,024 / 4,024 W
same, 1,300 W house car 0 Wh; hour means 1,300 W starts at :24 each hour; about 8.0 kWh; hour means 3,948 / 3,979 / 3,979 W
7.5 kW ceiling, 5 kW house (the old #1699 case) never starts starts at :32 each hour at 4,140 W; hour means 6,918 / 6,921 / 6,921 W
7.5 kW ceiling, 40 min at 500 W, car plugged in at 22:40 beside 6 kW / 8 kW idle to 23:00 runs from 22:40:01 at 9,660 / 7,590 W, held by the fuse margin; 22:00 hour means 5,536 / 5,515 W. At 23:00 the new hour stops it. 6 kW: restarts 23:42:14, 23:00 hour mean 7,234 W. 8 kW: the house alone is over the ceiling, so it stays idle. From 23:01 to midnight no tick says fuse_limit.
7.5 kW ceiling, 46 quiet minutes (rise level above the fuse), car plugged in at 21:46 beside 8 kW idle idle to 22:00 (last-quarter rule), every tick import_limit

Tests:

  • TestImportCeilingNoStartOnSlackAlone becomes TestImportCeilingStartsOnHoursSlack, with the first three rows. Over three hours, in every clock hour:
    • the car starts at least once;
    • it stops at most once, and never within 15 minutes of a start;
    • the hour's mean holds the ceiling;
    • every tick says import_limit.
  • TestImportCeilingNightNeverSaysFuse is the fourth row.
  • TestImportCeilingStartTestSaysImportLimit is the fifth row and pins the last-quarter rule.

Mutation checks:

  • With master's math.Min(riseW, ceiling), TestImportCeilingStartsOnHoursSlack (all three cases) and TestImportCeilingNightNeverSaysFuse fail.
  • Without the last-quarter rule, TestImportCeilingStartTestSaysImportLimit fails: the car starts at 21:46:01.

Runs:

  • go test ./internal/... ./cmd/ftw/... -count=1 passes, with drivers fetched by make drivers. go vet ./internal/... ./cmd/ftw/... is clean.
  • go test -race ./internal/control/ -count=1 passes.
  • go test ./internal/control/ -run TestSiteSimFieldMatrix: 340 cases, 29 fail as known (idle 23, own-raise 6). testdata/sitesim_field_known.txt is unchanged. No matrix row sets the ceiling, so this change cannot move it. The ceiling scenario in sitesim_test.go ("end of a heavy hour, low phases") still passes.
  • An independent review (separate agent) traced the hour's mean through restarts with and without SeedHourImport, meter gaps, a ceiling changed mid-hour, zones and battery pending orders, and found no bug. Its random-load runs (house spikes, Core restarts, gaps; 80 and 96 car-hours at 4.5 and 7.5 kW) had no hour above the higher of the ceiling and the house-only mean. Its wording points are fixed in the changeset and the commit message. The rest are listed under Limits.

Checklist

  • The change follows VISION.md and one selected scope.
  • I checked overlapping PRs and coordinated shared files/contracts (see Overlap).
  • Relevant checks cover the changed behaviour and failure paths.
  • A human reviewed changed UI in a browser, or no UI changed (no UI changed).
  • A Changeset is included, or the change is exempt (patch).
  • Every commit has a DCO sign-off.

🤖 Generated with Claude Code

… ceiling

The hourly import ceiling caps each clock hour's mean import, but the idle
start held the car to the ceiling itself: evMeterRoom set the start level
to min(rise level, ceiling), and stepEVIdleStart needs that level less the
dwell's mean import to hold the car's minimum. When house load plus the
minimum sat above the ceiling, the car never started, however much of the
hour was left. A help report showed a 4.5 kW ceiling, 0.85–1.3 kW of house
load and a 4,140 W minimum: 0 W to the car for a whole hour with 3.3–3.9 kWh
of room.

The start level is now the rise level, which already follows the hour's
rules: it starts each hour at the ceiling, rises in whole steps with one in
hand, waits 5 minutes after a cut and does not rise in the last 15. The
rate cut still guards the hour's energy. In the hour's last quarter the
start level stays at most the ceiling, as before, so Core never orders
a start on slack with less than 15 minutes of the hour left.

Tests: TestImportCeilingNoStartOnSlackAlone becomes
TestImportCeilingStartsOnHoursSlack, with the help report (850 and 1,300 W
house, 4.5 kW) and the old 5 kW / 7.5 kW case. Over three hours the car
starts in every hour, stops at most once in each and never within 15
minutes of a start, every hour's mean holds the ceiling, and every tick
says import_limit. TestImportCeilingNightNeverSaysFuse now lets the car run
on the 22:00 hour's slack and checks the label from 23:01 to midnight.
TestImportCeilingStartTestSaysImportLimit pins the last-quarter rule.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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-25T09:06:54.332508Z 09dfa52 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.

@frahlg
frahlg merged commit 8e33174 into master Oct 25, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 25, 2026
#1837)

* test(site): an import ceiling and two equal batteries in the site pack

s16 runs Charge now under a 4.5 kW hourly import ceiling with house and
the car's minimum above it (#1828). s17 runs two equal batteries under
Energyplan (#1814). FTW_SITE_ENGINE picks the planner for a whole run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* test(site): s17 waits for a quarter the plan discharges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* test(site): s17 plans 6 h ahead so the plan discharges now

Over 48 h Energyplan keeps both batteries for tomorrow's dearer evening and
the plan's current quarter stays idle, so Core's per-battery path never ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

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