Skip to content

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

Merged
frahlg merged 3 commits into
masterfrom
fix/ev-not-following
Oct 22, 2026
Merged

frahlg merged 3 commits into
masterfrom
fix/ev-not-following

Conversation

@frahlg

@frahlg frahlg commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

Field report, 22 Oct 2026, v0.155.0-beta.1, Easee Cloud. The plan asked for 7.6 kW on three phases. The car drew 3.13 kW all night (about 13.6 A on one phase) and took about 13 kWh of the 24 kWh planned. At 06:32 FTW cut the order to 0 W for the main fuse and the charger kept drawing 3.13 kW. The feedback said "Limited by the main fuse", with the receipt "Sent ✓, Accepted ✓, Measured: Waiting for fresh readings". It never said the charger was not following.

Cause

  1. classifyControlFeedback (go/internal/api/api_control_feedback.go:435 on master) returns limited for fuse_limit, import_limit, charger_limit, fuse_cooldown and the two stale-meter stops before it compares the charger's readings with the order. Under any of these reasons a charger that draws against the order reads as Limited for as long as it draws. Only the no-draw case (no_power_response) was checked first.
  2. Easee Cloud readings were fresh. The driver records power only on change but marks each poll control_power_confirmed (device-drivers#149, in beta.1's pin), so observeCommand sees a new observation every poll (command_evidence.go:407-435). "Waiting for fresh readings" is the receipt for evidence still at accepted: a cut is a material step and starts a new comparison, which needs 10 s of readings before it counts as measured. The screenshot was taken in the minute of the cut.
  3. Command evidence kept the last eight orders (command_evidence.go:161). Core sends a charger its order on every control tick, so the two-minute response window shrank to 16 s at a 2 s tick (40 s at 5 s). A slow cloud charger could be judged on an order it could not have applied yet.

Change

  • A limit explains a lower order, not a draw that differs from it. When a charger's fresh readings have disagreed with the orders in force for two minutes, the verdict goes to the normal comparison instead of limited:
    • drawing more than ordered is power_while_idle or power_above_target: Not following, a warning, and an alarm ten minutes after the two-minute wait, counted only from when the draw went above the order (PowerOverSince), since FTW cannot stop it;
    • drawing less is power_below_target: Not following, a warning, as under a plan order today. It does not escalate: a car that takes less only charges slower.
  • A charger that follows the limited order still reads Limited, as before.
  • A repeat of the last order is not stored again in command evidence, so the earlier order stays in force for the whole response window.

No fuse logic, setting or client word changes. The web words for power_while_idle already read "Asked to stay idle, but it is charging 3.1 kW."

Evidence

Field trace replayed through telemetry, command evidence and the API (TestChargerDrawingAgainstAFuseStopIsNotFollowing): an hour at 7.6 kW, then 0 W for the fuse, an Easee-like charger confirming 3.13 kW every 5 s.

Verdict while the charger draws against the 0 W order
master Limited by the main fuse for all 20 min
this PR Limited for the first 4 min 5 s, then Not following (warning, evidence measured), alarm from 14 min 5 s
  • TestChargerFollowingAFuseStopStaysLimited: a charger that stops after 100 s or 175 s (Easee's declared 180 s) stays Limited throughout, never an alarm.
  • TestOverdrawAfterAShortfallAlarmsOnItsOwnTime: 13 min at 3 kW against a 7 kW order, then 9 kW: a warning at once, an alarm after 12 min of overdraw. With the run counted from the first mismatch in either direction it was an alarm after 5 s (review finding).
  • TestCarBelowAFuseLimitedOrderIsNotFollowing: 3.1 kW against an 8.3 kW fuse-limited order is Not following, a warning, and stays a warning after 30 min. On master it read Limited.
  • TestRepeatedOrderKeepsEarlierOrderInForce: fails on master (the 7.6 kW order is out of force 16 s after a cut repeated every 2 s), passes here.
  • go test ./internal/api/... ./internal/telemetry/... ./internal/control/... ./internal/loadpoint/... ./internal/ocpp/... ./internal/sitesim/... ./cmd/ftw/... passes.

Not in this PR

  • Command history keeps 8 orders. With a repeated order stored once, the window holds unless the order changes on every tick; then it still shrinks to 8 ticks. Follow-up: keep orders by age, at least max(ResponseDelay, declared power age), with a larger hard cap.

  • no_power_response after a rise at 2 min 5 s for a charger that applies at 175 s is older than this PR.

  • The alarm sends no push. No kind in contract/push-catalogue.yaml fits ("Charging stopped early" says the opposite), and the catalogue is paired with ftw-webapp. It needs its own paired PRs.

  • When the charger's own readings are stale, FTW already shows No contact, and Lost control (alarm) after two minutes. The site meter alone cannot tell the charger's draw from other loads without a clear step, so it does not turn No contact into Not following here.

  • The one-phase draw itself (a three-phase order the charger serves on one phase) and the plan's rate for it: separate PR.

Open PRs #1789, #1762 and #1537 touch the same function; the change here is one guard around the limit return and one flag.

🤖 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-22T05:22:00.253056Z 0992c7d 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: 0992c7d13d

ℹ️ 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/api/api_control_feedback.go Outdated
// FTW cannot stop a charger that draws more than it is told. A car
// that takes less only charges slower.
if f.Kind == "ev" && (reason == "power_while_idle" || reason == "power_above_target") {
f.Escalated = cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= evMismatchWait+evOverdrawAlarm

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 Track sustained overdraw separately before raising an alarm

If a car draws below its order for more than twelve minutes and then jumps above it without an intervening matching reading, the first above-target reading immediately raises an alarm. PowerMismatchSince tracks disagreement in either direction: observeCommand does not reset it when the gap changes from underdelivery to overdelivery. For example, a charger reporting 3 kW against a steady 7 kW order can jump to 9 kW and immediately inherit the entire underdelivery duration. Track or reset a separate overdraw timer so the alarm requires the promised sustained overdraw rather than counting time when the car was merely charging slowly.

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Oct 22, 2026
All 29 rows labelled #1795 in the field matrix pass: a charger that draws
against its order reads not following. No other row changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg force-pushed the fix/ev-not-following branch from 366e6c1 to f81ffe1 Compare October 22, 2026 08:02
frahlg added a commit that referenced this pull request Oct 22, 2026
#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 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
… cuts a running order (#1799)

* fix(loadpoint): planned charging runs on three phases, and room never 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>

* fix(loadpoint): review findings on three-phase charging and the kept 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>

* test(control): field rows after three-phase charging

#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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 3 commits October 22, 2026 11:10
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>
…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>
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
frahlg force-pushed the fix/ev-not-following branch from f81ffe1 to f19b3b0 Compare October 22, 2026 09:12
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 5ae590a into master Oct 22, 2026
22 checks passed
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