Skip to content

fix(loadpoint): planned charging runs on three phases, and room never cuts a running order - #1799

Merged
frahlg merged 3 commits into
masterfrom
fix/ev-plan-three-phase
Oct 22, 2026
Merged

frahlg merged 3 commits into
masterfrom
fix/ev-plan-three-phase

Conversation

@frahlg

@frahlg frahlg commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

Field report, 22 Oct 2026, v0.155.0-beta.1: a 3×20 A site, Easee Cloud, loadpoint phase_mode: auto, a step table with one-phase levels (1,600–3,800 W) and three-phase levels (4,800–11,100 W), and a plan for about 24 kWh by 09:00. The car is three-phase. All night it charged on one phase at about 3.3 kW, in runs of about 3 minutes with about 4 minutes off between them. The busiest phase never went above 17 A against a working limit of 19 A, and the site meter never showed an overage. After 04:40 the charger went to three phases and the car took 8.3–9.7 kW.

Causes

  1. The plan picked one-phase power. PlanningSteps passes an explicit step table to the planner unchanged, so the planner could legally spread the night at 3.1–3.5 kW. Only one phase can deliver that, so Core sent auto orders below the 3,680 W split and the Easee ran on one phase. The site cap for an auto loadpoint is counted on one phase (evPoolFor), which kept it near 3.5 kW.
  2. The split between cars cut a running order with no overage. When the site cap rose one step past the split, phaseRoomCapW read the order as three-phase. It credited the car's current one-phase order with nothing (creditA needs the same phases) and capped it at the room left on the car's own phase, about 2–3 A. That room cap was then sent as a one-phase order. Each tick credited less, so the order went 3,500 → 1,600 → 0 W. Before fix(control): a cut ends the reservation of the higher orders before it #1793 the cut also kept the earlier order reserved for 180 s, which made the 4-minute gaps.

Change

B, three phases for planned charging and Charge now (Fredrik, 22 Oct 2026):

  • threePhaseCapable: a charger can run three phases when the site has three, it is not pinned to 1p, and its step table, or its max_charge_w without a table, reaches 6 A on each phase (4,140 W at 230 V). A table with nothing that high is a one-phase charger, whatever its mode says.
  • resolvePhaseMode: an operator pin (1p, 3p) is sent as is. Unset and auto mean the same: 1p for a one-phase charger, otherwise 3p. Only surplus charging without a schedule may use one phase: the day lock, the 30 min dwell, then auto with the driver's hold. A solar-only loadpoint's goal does not count as a schedule, since it charges from surplus anyway.
  • holdPhaseMode: Charge now on unset or auto is 3p (1p on a one-phase charger). A hold below the three-phase minimum goes out as auto, so a 1,380 W Start is not lost.
  • PlanningSteps: on a three-phase charger the planner gets the explicit steps at or above 6 A on each phase, plus 0, or the whole-amp three-phase ladder without a table. A one-phase charger keeps its table, or gets the one-phase ladder. LimitPower's minimum is never below the lowest step.
  • The controller snaps the plan's power to the steps legal on the phases its command names (fuseConfig), so a three-phase order never goes out at 2,760 W.

A, the split never cuts a running order on room alone: room limits rises. phaseRoomCapW keeps the current order when the order it allows lands on the same phases; only a measured overage cuts, through the site cap that the split never exceeds. The same watts on other phases are a rise on some of them, so a move between one phase and three gets the room alone.

Fuse rule unchanged: the site meter's current per phase plus FTW's own orders that cannot have shown yet. No setting added. The car-phases wording matches #1792: the step rule is "6 A on the car's phases", three unless pinned, so #1792's 2p branch sits next to the 1p one.

Evidence

Replay of the night in the closed-loop sim (night plan, auto, one-phase steps): 3×20 A, house 1.6/1.7/2.3 A, Easee applying in 40 s, the field step table, a plan for 11.1 kW. The sim adapter now splits auto as the Easee driver does when no split is sent.

40 minutes master A only A and B
Phases 1 1 3
Order 3,500 W, cut to 1,600 W and back every ~7 min 3,500 W steady 10,400 W
Energy 1.92 kWh 2.26 kWh 6.79 kWh
Cuts with every phase under its limit 5 0 0
  • The row fails on master ("car draws 3450 W on 1 phases") and passes here.
  • TestSplitKeepsTheCurrentOrder gives the field numbers on master (cap 1,600 W, then 0 W with no room) and keeps 3,500 W here; a three-phase order moving to one phase stays held to the room.
  • TestPlanningStepsKeepThreePhaseLevels: the field table on 3×20 A gives no planner step between 0 and 4,800 W for auto, unset and 3p; 1p keeps its table; a charger with max_charge_w 3,680 keeps its steps.
  • TestChargeNowRunsOnThreePhases, TestResolvePhaseMode.
  • Existing tests that pinned auto as one phase under a schedule now pin the new rule; the two that tested the shared budget's phase check use an explicit 1p loadpoint, so they still test it.
  • go test ./internal/api/... ./internal/telemetry/... ./internal/control/... ./internal/loadpoint/... ./internal/sitesim/... ./internal/mpc/... ./internal/config/... ./cmd/ftw/... passes.

Review findings (independent review of 5fb6ad8 with #1795), fixed in ed759ec

Finding Fix Test
A1 1p 5,000 W then a 3p order with 2 A of room kept 5,000 W: +7.2 A on two phases with no car current keep only on the same phases TestSplitKeepsTheCurrentOrder "one-phase order moving to three phases": 0 W
B1 solar-only with a goal on auto sent 2,300 W as 3p; Easee 3.3 A, never charged a solar-only goal is not a schedule here TestSolarOnlyWithGoalKeepsOnePhaseSurplus: 2,300 W on 1p
B2 one-phase-only table, max 5,520 W, auto: planner 3p ladder, controller 0 W threePhaseCapable: such a charger keeps its 1p steps and orders TestPlannedOrderFollowsTheChargersPhases: 3,680 W 1p; TestPlanningStepsKeepThreePhaseLevels
B4 unset under a schedule sent a 2,300 W unset order, Easee 3.3 A on 3p unset = auto: 3p raised to 4,140 W, or 1p on a one-phase charger TestPlannedOrderFollowsTheChargersPhases, TestEmptyPhaseModeIsThreePhase

Also a test: a measured cut always beats a kept order (site cap 2,000 W after an overage gives at most 2,000 W).

Known limit, stated in the code: a one-phase charger above 16 A whose table reaches 4,140 W reads as three-phase and needs phase mode 1p, as #1791 already says for its current cap. A physically one-phase car on a three-phase charger (B3) is left for the next PR.

Notes

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 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-22T06:04:24.804246Z 5fb6ad8 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: 5fb6ad8332

ℹ️ 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/loadpoint/planning_steps.go Outdated
Comment on lines 31 to 35
if len(steps) > 1 {
return steps
}
}
minimum := math.Max(6, math.Ceil(cfg.MinChargeW/step))

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 Use the same fallback steps for planning and dispatch

When an explicit step table contains no three-phase levels, this falls through to a generated three-phase ladder, but dispatch still snaps against the original AllowedStepsW. For example, the configuration added in TestPlanningStepsKeepThreePhaseLevels with PhaseMode: "auto", MaxChargeW: 5520 and steps [0, 1380, 2300, 3680] gives the planner [0, 4140, 4830, 5520]. However, computeCommandForBudget now receives a 4140 W minimum through fuseConfig, so SnapChargeW finds no eligible configured step and returns zero for every positive planned allocation; freezeFuseBudget likewise assigns zero. The plan therefore promises energy while the car never charges. Normalize the dispatch steps consistently with this fallback, or avoid advertising steps that dispatch cannot issue.

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Oct 22, 2026
Twenty-four rows from #1799's review and fix D: a charger whose
table holds only one-phase levels (max 5,520 W, auto), phase mode unset
with a goal and a 2,300 W plan slot, surplus-only with a goal and 2.5 kW
of export, a held 1p order raised to 3p beside a phase at 20 A, the help
report's site with a one-phase car, and a long L1 step under a
three-phase car.

Surplus cases get the planner's PV forecast as pickSurplusSteps reads it,
so the one-phase rule is judged as Core makes it: one phase only when the
next 30 min stay below three phases at 6 A. Surplus cuts follow the
export and are no longer judged as fuse cuts. The trace also logs a
change of reason.

New label hold3p: the app sends Charge now as 3p for an auto loadpoint,
and a charger whose table stops below 4,140 W gets 0 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…otection (#1800)

* test(control): field matrix for EV charging and fuse protection

v0.155.0-beta.1 passed our tests and failed on field sites the same
night. We tested our defaults: fixed 3p, no step table, planned charging
stubbed, and chargers that applied an order at once or exactly when they
declared. Easee Cloud applies in 30-60 s and declares 180 s.

TestSiteSimFieldMatrix runs Core's EV charging and fuse protection in the
closed loop against 314 cases: the field reports (the help report of
22 Oct, the site in #1763, the home box) with every charger timing,
a sweep over fuse, phase mode, step table, car phases and house load, and
a timing sweep over every request. Planned charging now runs through the
real controller: a minimal planner sends the slot's Wh and on-power, from
the steps main.go gives the planner.

Each case runs in a synctest bubble, so command evidence, the API's
verdict and live Home use run on the plant's clock. Invariants, every
second: the fuse holds; FTW's limit as before; no fuse cut while every
phase is under the limit; no second cut while an unapplied cut covers the
overage; a planned charge delivers 90 % of what the fuse allows; planned
charging and Charge now never put a three-phase charger on one phase; a
charger drawing against its order reads not following; Home use stays
measured while every device reads.

testdata/sitesim_field_known.txt lists the rows that fail on master and
the fix that turns each green. The sim gains the Easee driver's auto
split and phase hold, and outside steps no longer count as our raise.
The matrix skips -race builds: it finds nothing there and runs 15 times
slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): the write mode logs what it would fail

* test(control): probe rows from the three-phase review

Twenty-four rows from #1799's review and fix D: a charger whose
table holds only one-phase levels (max 5,520 W, auto), phase mode unset
with a goal and a 2,300 W plan slot, surplus-only with a goal and 2.5 kW
of export, a held 1p order raised to 3p beside a phase at 20 A, the help
report's site with a one-phase car, and a long L1 step under a
three-phase car.

Surplus cases get the planner's PV forecast as pickSurplusSteps reads it,
so the one-phase rule is judged as Core makes it: one phase only when the
next 30 min stay below three phases at 6 A. Surplus cuts follow the
export and are no longer judged as fuse cuts. The trace also logs a
change of reason.

New label hold3p: the app sends Charge now as 3p for an auto loadpoint,
and a charger whose table stops below 4,140 W gets 0 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 3 commits October 22, 2026 10:02
… cuts a running order

Field report, v0.155.0-beta.1, 3x20 A, Easee, phase_mode auto, a step
table with one-phase levels: a three-phase car charged on one phase at
about 3.3 kW all night, cut to 1,600 W and 0 W with every phase under
its limit.

Planned charging and Charge now run on three phases (Fredrik, 22 Oct
2026). auto is 3p; only surplus charging without a schedule may use one
phase (day lock, dwell, then auto). An unset mode stays the driver's
default. The planner gets explicit steps at or above 6 A on the car's
phases, or the three-phase ladder; LimitPower's minimum is the lowest
step. The controller snaps the plan to steps legal on its command's
phases.

The split between cars kept no credit for a one-phase order once the
site cap passed the split, and cut it to the room on the car's own
phase. Room now limits rises only: the current order is kept when the
order it allows lands on as many phases or more. Only a measured
overage cuts.

The closed-loop sim splits auto as the Easee driver does and replays
the night: master 1.92 kWh in 40 min with 5 cuts with room, now 6.79
kWh on three phases with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…order

A1: the split keeps the current order only when the order it allows
lands on the same phases. 5,000 W moved from one phase to three adds
7.2 A to two phases that carried none of it.

B1: a solar-only loadpoint with a goal charges from surplus, so its
schedule no longer forces three phases; 2.5 kW of surplus goes out on
one phase again instead of an order Easee turns into 3.3 A.

B2, B4: threePhaseCapable decides from the step table, or the maximum,
whether the charger reaches 6 A on three phases. If not, it is a
one-phase charger: its steps and orders stay one-phase. If so, unset
means auto: three phases, with orders raised to the 3p minimum, never
a 2,300 W unset order Easee runs at 3.3 A.

Each review probe is a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1799 turns 192 B rows, 20 A rows and one #1795 row green. It makes 35
one-phase-car rows with planned charging fail on energy, verdict or a
second cut: a one-phase car takes a third of a three-phase order, which
#1801 (D) handles. One 32 A one-phase charger probe now shows the second
cut of the netting row class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the fix/ev-plan-three-phase branch from ed759ec to 74603d0 Compare October 22, 2026 08:04
frahlg added a commit that referenced this pull request Oct 22, 2026
…field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 04dbdd2 into master Oct 22, 2026
22 checks passed
frahlg added a commit that referenced this pull request Oct 22, 2026
On master with #1799: all 28 rows labelled #1795 pass, and 16 D rows whose
only failure was the verdict now read not following. No row fails that
did not before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg deleted the fix/ev-plan-three-phase branch October 22, 2026 09:14
frahlg added a commit that referenced this pull request Oct 22, 2026
…ing (#1795)

* fix(feedback): a charger that draws against FTW's order is not following

A Core limit returned "limited" before the charger's readings were
compared with the order. In 0.155.0 an Easee Cloud charger went on
drawing 3.1 kW after FTW cut it to 0 W for the main fuse, and the
verdict stayed "Limited by the main fuse". A limit explains a lower
order, not a draw that differs from it. A settled mismatch now goes to
the normal comparison: drawing above the order is power_while_idle or
power_above_target, a warning that becomes an alarm after ten more
minutes; drawing below it is power_below_target, a warning, as under
a plan order.

Command evidence kept the last eight orders. Core sends a charger its
order on every tick, so the two-minute response window shrank to 16 to
40 s and a slow charger was judged on an order it could not have
applied yet. A repeat of the last order is no longer stored again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(feedback): the overdraw alarm counts only time drawing above the order

PowerMismatchSince covers both directions. A car that drew 3 kW against
a 7 kW order for 13 minutes and then rose to 9 kW was an alarm on the
first reading above the order. Command evidence now keeps the start of
the run above every command in force (PowerOverSince), and the alarm
waits two minutes plus ten from there.

Tests: a cut obeyed at 175 s stays Limited; under then over warns at
once and alarms after 12 minutes of overdraw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): delete the field rows #1795 turns green

On master with #1799: all 28 rows labelled #1795 pass, and 16 D rows whose
only failure was the verdict now read not following. No row fails that
did not before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 22, 2026
…nned and ordered as one (#1801)

* fix(loadpoint): a one-phase car on a three-phase charger is seen, planned and ordered as one

A one-phase car draws a three-phase order's current per phase on one
phase. On the field table the planner got [0, 4800], the car took
1,600 W of a 4,800 W order, and the feedback said not following.

The loadpoint now tests each rise of its three-phase order on the site
meter: one phase rising by about the order's rise, the other two still,
for 30 s within the charger's response time, while the charger's own
reading shows that draw. That marks the session's car one-phase
(State.CarPhases, the field a car profile can set later); a rise on all
three phases clears it, and unplugging resets it. A house load does not
follow the orders, and a two-phase car moves two phases, so neither is
marked.

Marked, the planner gets the car's one-phase levels, 6 A up to the
charger's current per phase. The charger stays on three phases and the
order is the plan's power times three, so its current per phase is what
the car should take. The command carries expected_w, a third of the
order, and command evidence compares the car with that. The verdict is
Limited, "Charging on one phase, at most 3.7 kW". The fuse limit is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car tests on fast and undeclared chargers; field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on #1799: 55 D rows, 28 #1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car check compares changes, guards a clear, and survives a restart

Review findings on #1801:

M1: the check compared the charger's whole draw with the meter's rise,
so only a rise from about 0 A could mark a car. It now compares the
change in the charger's draw since the baseline.

M2: a three-phase verdict had no draw check, so a heat pump starting at
2 A a phase during a 2 A rise cleared a correct mark. It now needs the
charger's draw to have risen by about the three phases' sum.

M3: the first reading of a session had a zero baseline, and the mark
lives in memory, so after a Core restart with the car charging it was
never marked again. A reading with no baseline is no longer a test, and
an unmarked car whose charger's whole draw shows on one phase while the
other two carry less than a third of it, for 30 s, is marked. No three-
or two-phase car can show that.

Each review probe is a test, and each fails on the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): judge a one-phase car after limits, setpoint and waiting

"Charging on one phase" ran before the limit guard, so a fuse limit,
import limit, fuse cooldown, stale site meter, changed setpoint or a
reading older than the order all read as limited/one_phase. The branch
now runs after the waiting check; those verdicts come first, and a draw
beyond tolerance is judged as for any car.

The field matrix judged the car's draw against the full order and let
only one_phase excuse a third of it. It now judges the draw against the
command's expected_w, as Core does, so a marked car under a fuse limit
reads fuse_limit without failing the verdict check. The known list does
not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): rewrite the field matrix's known list on #1802

On master with #1802, the 39 one-phase car energy rows (D) and the one
energy row labelled A pass with this branch; their lines and labels go.
No row is new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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