Skip to content

fix(control): the import ceiling caps each clock hour's mean import (#1674) - #1699

Merged
frahlg merged 4 commits into
masterfrom
1674-hourly-import-budget
Oct 17, 2026
Merged

frahlg merged 4 commits into
masterfrom
1674-hourly-import-budget

Conversation

@frahlg

@frahlg frahlg commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Problem and result

#1674: the import ceiling (peak_import_ceiling_w) acted every second. Under a 7.5 kW ceiling with 0.9 kW of house load, #1695 holds the car at 5,520 W with one step free, and a short house peak cuts it. Swedish power tariffs bill the mean import over each clock hour, and the planner already models demand charges per local clock hour (mpc/demand.go). The ceiling throttled more than the tariff needs. Fredrik agreed on 17 October that it should act on the hour's mean. The fuse stays an instant limit.

Göran's support dump from 17 October (v0.147.2-beta.1, 14:26–14:29 UTC) shows the cost. The ceiling went on with the car at 10,350 W and about 3.3 kW of house load. The car came down to its 4,140 W minimum. One reading of 7,524 W, 24 W over the ceiling, then stopped it (overage_w=24 cap_w=4116). After that the room was about 4.2 kW, short of the minimum plus a step (4,830 W), so it never restarted until he turned the ceiling off. On the hour's mean, 3.3 + 4.14 kW = 7.44 kW holds a 7.5 kW ceiling.

go/internal/control/import_hour.go keeps the clock hour's import and two limits.

  • Import so far, site meter only. ObserveSiteImport runs every control tick (main.go, under ctrlMu, before the loadpoint tick). It adds the positive grid power between readings, split at local clock hours in the box's zone, the zone the planner uses. It reads the meter through Store.OnlineMeterW, a new health-gated accessor in go/internal/telemetry, so a watchdog-offline meter's last value never counts as a new reading. No charger or battery reading takes part.
  • Limit = (ceiling × 1 h − imported) / time left. This is the import the rest of the hour may hold. It replaces the ceiling in effectiveImportCeilingW, so the battery fuse guard and the joint allocator use it, and the EV guard cuts on it. It is never above the fuse less its margin.
  • Short overages wait. The EV guard keeps the fuse's part (total import and worst phase) apart from the ceiling's part (evMeterRoom). The fuse still cuts on every reading. The ceiling cuts at once on an overage of a charging step or more, after a meter gap, or while the hour is not yet measured. A smaller overage must last 20 s first, so one reading over the ceiling changes nothing. The energy it lets through counts against the hour and lowers the limit at the next minute.
  • Rise level = what an increase may reach: the EV guard's rise and idle start, and battery charge beside an EV that still wants power (EV first, fix(control): give a planned EV its fuse share on the worst phase #1536). Each hour it starts at the ceiling. It rises only in whole steps (1 A × V × phases), when the limit is two steps above it, and it keeps one step in hand. It does not rise within 5 minutes of a cut or in the hour's last 15 minutes, so a rise lasts before the next hour starts again at the ceiling. Between the rise level and the limit the EV holds.
  • Starts and restarts. The fuse's part still needs room for the minimum plus a step on every reading for 35 s. The ceiling's part is judged on the mean import over those 35 s, the house's load rather than one reading of it. It needs room for the minimum only, under the rise level and never above the ceiling itself. With 3.3 kW of house load the car restarts at 4,140 W. With 5 kW it does not start: on the hour's slack alone it would start at about :35 and stop at :00, once an hour.
  • A ceiling set during the hour counts from then. The rest of the hour may average it, whatever the hour imported before it was set. Otherwise, in the test of Göran's case, the half-hour at 13.65 kW before the ceiling would leave a 2.8 kW limit and stop the car outright. A restart keeps the hour's record.
  • Slow. Both move at most once a minute, on a fresh meter reading. A new hour, a new ceiling or a box with no reading yet uses the ceiling for both.
  • Meter gaps. Readings more than 20 s apart count at no less than the hour's rate when the gap began, or the higher reading. A gap can never raise the hour's rate, and the reading that ends it never raises a limit.
  • Restart. At boot, main.go reads the hour's import from state.db (ImportWhIntervals, the planner's source) and seeds it. Time the record does not cover counts at the ceiling.
  • Status. /api/status adds peak_import_hour_left_wh and peak_import_limit_w beside peak_import_ceiling_w, null while it is off. No new setting; the tariff model has no window length.
  • The EV guard's site meter over limit log line now names import_limit_w.

Near the hour's end the limit is the exact budget rate, so a cut takes the smallest step that keeps the hour inside it. The car stops only when even its minimum beside the house would overrun the hour.

One departure from the brief

The brief suggested taking a house-load reserve, such as the mean house load, off the limit. I did not. The EV guard's room is the limit less the measured import, and the measured import already holds the house. Taking the house off again counts it twice: at 23:45 the limit would be 7.5 − 0.9 = 6.6 kW against 7.11 kW measured, and the car would be cut. The rise rules do the reserve's work instead.

Scope and safety

  • Stale meter: dispatch still stops after a minute, as before. A silent meter adds nothing and moves no limit; the gap is counted when readings return.
  • Failure and recovery: no record or a failed ImportWhIntervals means the hour so far counts at the ceiling, which is the old behaviour.
  • 5 minutes and 15 minutes are judgment calls, not tariff facts.
  • Once an hour's budget is spent, its limit is 0 for the rest of that hour: the car stops and the battery covers the house. That follows "stop only if the budget would otherwise be exceeded", since every further Wh raises the billed mean. A small overrun near the hour's end, from a house step the guard cannot answer in time, can therefore stop the car for the last minutes. A floor would trade a few watts on the mean for fewer stops; that is Fredrik's call.
  • Unseen time, whether a boot without history or a failed ImportWhIntervals, counts at the ceiling. That is neutral, the old behaviour, but it understates an hour that ran above the ceiling. The legacy history path (state/cost.go) counts a NULL grid_w as covered 0 W; it is used only before the site-energy tables exist.
  • A meter that reports less often than every 20 s makes every interval a gap. The hour then never gains room, which is safe but keeps the old behaviour.
  • The changeset is patch, as the brief asked. The ceiling's meaning changes without any user action, so minor is arguable.
  • evMeterRoom keeps the fuse's part and the ceiling's part apart, and importHourLimitsW() returns the ceiling's limits alone, for work that stacks on this (the fuse heat budget).
  • The battery and the EV still react to the same large overage. In Göran's dump the battery bridged the overage (−4.8 kW at 14:26:25; at 14:22:38 it reached −9.35 kW and the site exported 4 kW for about 10 s as the car stopped). The battery's fuse guard and the EV guard both act on the same overage in the same tick, so when the car's cut lands the battery is still discharging. The hourly mean removes this only for small overages: under a step the EV now waits, and the battery covers the few watts. When the ceiling is first set with the car at full power, or a new hour starts after the car spent the last hour's slack, the overage is large and both still react. Fixing that means the battery's guard counting the EV cut we just commanded, our own command. That is a separate change.
  • On the first reading after a restart, fix(control): the EV guard trusts only the site meter and our own commands (#1674) #1695's guard may give the car its whole measured room at once instead of one step. That is existing behaviour and is unchanged here; the hour's limits still bound it.
  • web/index.html: the ceiling's hint now says it caps the hour's average. .card-hint is display: none, so nothing visible changes. Checked in a browser.
  • Overlaps fix(ev): preserve confirmed power history and verify charging recovery #1675, which already needs a rebase onto master after fix(control): the EV guard trusts only the site meter and our own commands (#1674) #1695: ev_fuse_budget.go (evMeterRoomW becomes evMeterRoom; the idle start moves to stepEVIdleStart), ev_battery_headroom.go (one line) and the State struct in dispatch.go.

Verification

No hardware run yet. Göran's site (segran2) is the natural first test, with the ceiling on. Everything below is simulated.

Simulated with the #1674 harness: an Easee-like 3-phase charger with 5 s command delay and 10 s late telemetry, a 25 A site and 1 s meter readings. go/internal/control/import_hour_test.go:

Case Result
(a) 23:45 case: 7.5 kW ceiling, 0.9 kW house, car from idle at 22:00 Starts at 6,210 W and holds it through 23:45 and midnight to 00:30. No stop. Hour means 7,099 W and 7,110 W.
Göran, 17 Oct: ceiling on at 16:26:08 with the car at 10,350 W and 3.3 kW house; one reading of 7,524 W at 16:27:59 Cut to 4,140 W at 16:26:09 and held there to 17:00; no stop. Import since the ceiling went on averages under 7,500 W. Without the 20 s wait the car stops at 16:27:59, as in the field.
Restart: 3.3 kW house, house at 3.7 kW for 2 min from 16:20 Stops at 16:20:21 after 20 s over the hour's limit, restarts at 4,140 W at 16:22:47, no further stop; hour mean 7,158 W. With the old restart rule (minimum plus a step on one reading) it never restarts.
Ceiling set at 16:26 after 13.65 kW since 16:00 Both limits are the ceiling from then on; the budget left is the ceiling for the time left.
(b) quiet half-hour, car plugged in at 00:30 10,350 W, grid 11.25 kW, hour mean 6,058 W.
(c) house 0.9 → 4.0 kW at 23:50 car 6,210 → 4,830 W, no stop, hour mean 7,398 W. A per-second 7.5 kW ceiling would stop it: 7.5 − 4.0 kW is under the 4,140 W minimum.
(d) 45 s meter gap while the house drops the gap counts at the hour's rate; the rate and both limits do not rise. A gap across the hour's end counts the new hour at the ceiling.
5 kW house load, 7.5 kW ceiling, 21:00–00:00 the car does not start; without the start rule it starts at 21:35:37.
clock jump from 1970 only the hour the reading falls in counts; unseen time in it counts at the ceiling.
(e) 9.5 kW sauna to 23:25, Core restart at 23:30 the hour's import survives the restart (within 10 Wh) and the hour ends at 7,244 W. Without the record the same hour ends over 7,500 W.

go test ./internal/control/ -race -count=3 passes. make verify passes (76 packages, vet and build clean), and npm test passes 760 of 760.

Existing tests that set a ceiling without feeding meter readings keep the old flat limit, so the guard's own behaviour (#1695) is unchanged and its tests still pin it.

Checklist

Refs #1674.

🤖 Generated with Claude Code

…1674)

The import ceiling acted every second. Under a 7.5 kW ceiling a short
house peak cut the car, and the 23:45 case held at 5,520 W. Swedish power
tariffs bill the mean import over each clock hour, the hours the
planner's demand charges use.

The control tick now adds the site meter's import to the local clock
hour. The hour's limit is the budget left over the time left; the EV
guard's cut, the battery fuse guard and the joint allocator use it. The
fuse stays an instant limit. Increases reach only a slower level: whole
steps with one in hand, at most once a minute, five minutes after a cut
and not in the hour's last quarter. A car starts only with room under the
ceiling itself, so it never starts on slack only to stop at the next
hour. A meter gap counts at no less than the hour's rate and never raises
a limit. At boot the hour's import comes from state.db. /api/status shows
the hour's budget left and the limit in force.

Overlaps #1675.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
… car (#1674)

Göran, 17 October 14:27:59 UTC: with the car at its 4,140 W minimum beside
3.3 kW of house load, one reading 24 W over the 7.5 kW ceiling stopped it,
and the room left (4.2 kW) never met the minimum plus a step again.

The EV guard now keeps the fuse's part apart from the ceiling's part. The
fuse cuts on every reading. The ceiling cuts at once on an overage of a
step or more, after a meter gap, or while the hour is not measured; a
smaller overage must last 20 s. A restart judges the ceiling on the mean
import over the 35 s wait and needs room for the minimum only, under the
rise level and at most the ceiling. A ceiling set during the hour counts
from then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg and others added 2 commits October 17, 2026 17:03
… gate

The telemetry-read guard counted two direct reads of the cached meter in
import_hour.go. Store.OnlineMeterW returns the site meter's power and
arrival time only while its driver is still reporting, so a
watchdog-offline meter's last value never counts as a new reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
@frahlg
frahlg marked this pull request as ready for review October 17, 2026 15:14
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 17, 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-17T15:18:08.004104Z 826d6f0 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: 826d6f03c7

ℹ️ 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/cmd/ftw/main.go
Comment on lines +3329 to +3330
start := control.ImportHourStart(now)
wh, covered, err := st.ImportWhIntervals(context.Background(), [][2]int64{{start.UnixMilli(), now.UnixMilli()}})

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 Preserve a mid-hour ceiling baseline across restarts

When the ceiling is enabled or changed partway through an hour and Core restarts before that hour ends, this query restores import from the beginning of the hour. At runtime stepHourImportLimits intentionally discards import from before the new setting, so after the restart that earlier import is incorrectly charged against the new ceiling and can sharply reduce the limit or stop charging. Persist the activation/baseline time or otherwise seed only the portion accumulated since the current ceiling took effect.

Useful? React with 👍 / 👎.

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