From ff7077c8c6bd7d13dd0c65632b2643322d8c50a3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 13:35:41 +0200 Subject: [PATCH 1/2] fix(control): a battery order on its way counts on each phase A one-phase charger's room is one phase's, but Core took the battery's whole unshown order off it, so a three-phase battery counted three times. With 4,703 W of battery order the car's phase had 661 W left, under the 1,610 W a start needs, and Charge now sat at 0 W (fuse_limit) with every phase near 0 A. The order now goes beside the chargers' unshown offers: in total import, and the same current on each phase. A three-phase charger's cap does not change. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-one-phase-battery-order.md | 5 ++++ go/internal/control/ev_fuse_budget.go | 27 ++++++++++++++++---- go/internal/control/ev_fuse_budget_test.go | 29 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 .changeset/ev-one-phase-battery-order.md diff --git a/.changeset/ev-one-phase-battery-order.md b/.changeset/ev-one-phase-battery-order.md new file mode 100644 index 00000000..51fc5b6a --- /dev/null +++ b/.changeset/ev-one-phase-battery-order.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A one-phase charger now starts while FTW raises the battery. Since 0.147.2 a car on a one-phase charger, or on a charger set to 1p, could sit at 0 W shown as limited by the main fuse with every phase far below its limit: FTW took the battery's whole new order, which the site meter could not show yet, off the room on the car's one phase, so a three-phase battery counted three times there. In the reported case Charge now waited 42 s; on a busy 16 A site with the battery stepping up every half minute the car did not start for 9 minutes. The battery's order now counts as a three-phase battery puts it, the same current on each phase. A three-phase charger's limit does not change. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 97945793..7e12d2df 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -389,7 +389,10 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo if _, high, _, ok := s.evOffers.span(now); ok { peakW = math.Max(peakW, high) } - r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at)) + // Our own battery orders the meter may not show yet count beside the + // chargers' offers, in total import and on each phase. + pending := s.pendingBatteryCommandsW(now) + r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.withBatteryOrders(s.pendingLoad(at), pending)) _, fresh := qualifiedFuseObservation(meter, now) fresh = fresh && r.measured lim.phaseRoomA, lim.phaseRoomKnown = 0, r.phaseRoomKnown @@ -465,11 +468,10 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo } // An increase needs a fresh reading and room below the level increases // may reach, left after our own battery commands that the meter may not - // show yet. Between that level and the import limit the cap holds. - pending := s.pendingBatteryCommandsW(now) + // show yet (in r). Between that level and the import limit the cap holds. room := 0.0 if fresh { - room = math.Max(0, math.Min(r.fuseW, r.riseW)-pending) + room = math.Max(0, math.Min(r.fuseW, r.riseW)) } if pool.offerW <= 0 { s.stepEVIdleStart(r, gridW, pool, first, gap, fresh, at, prevAt, pending, step) @@ -503,7 +505,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first } room := 0.0 if fresh { - room = math.Max(0, perReadingW-pending) + room = math.Max(0, perReadingW) // r holds our battery orders. } if room < pool.minW+step { lim.roomSince = time.Time{} @@ -602,6 +604,21 @@ func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, pre return worstA, present, valid } +// withBatteryOrders adds battery increase we ordered that the meter may not +// show yet, w, to the chargers' unshown offers: in total import, and as a +// three-phase battery puts it, the same current on each phase. A one-phase +// pool's room is one phase's, so the battery's whole power there would count +// it three times. +func (s *State) withBatteryOrders(p pendingLoad, w float64) pendingLoad { + p.w += w + if s.SiteFuseVoltage > 0 && s.SiteFusePhases > 0 { + for i := range p.phaseA { + p.phaseA[i] += w / (s.SiteFuseVoltage * float64(s.SiteFusePhases)) + } + } + return p +} + // pendingBatteryCommandsW is battery increase we ordered that the meter may // not show yet. Signed, so a stopped discharge counts too. func (s *State) pendingBatteryCommandsW(now time.Time) float64 { diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index f073c371..1a5233d9 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -751,6 +751,35 @@ func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { } } +// Our own battery order the meter may not show yet counts as a +// three-phase battery puts it: the same current on each phase. A one-phase +// car's room is one phase's, so the whole order there counted it three +// times: 4,703 W of battery order left 661 W below one phase's limit, under +// the 1,610 W a start needs, and the car stayed at 0 W (fuse_limit) until +// the order had shown for evSettle and the meter had shown room for +// evRestartDwell (the rig of 22 Oct 2026, 42 s). The cap counts 6.8 A on +// each phase instead; a three-phase car's cap does not change. +func TestEVHardLimitBatteryOrderCountsOnEachPhase(t *testing.T) { + for _, tc := range []struct { + phase string + want float64 + }{ + {"1p", (24 - 0.68 - 4703.0/690 - 1) * 230}, + {"auto", (24 - 0.68 - 4703.0/690 - 1) * 230}, + {"3p", (24 - 0.68 - 4703.0/690 - 1) * 690}, + } { + t.Run(tc.phase, func(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, -466, []float64{-0.68, -0.68, -0.68}, 0, tc.phase) + now := time.Now() + state.batteryCommands = map[string]commandLog{"ferroamp": commandLog{}.add(now, 4703)} + got, known := EVFuseBudget(store, state, points, configs, 17250) + if !known || math.Abs(got-tc.want) > .01 { + t.Fatalf("cap %.1f W, want %.1f W", got, tc.want) + } + }) + } +} + // A lone 0 W or unavailable charger reading is not a reason to cut, and // before our first command a charging car's reading earns no credit: the // meter alone decides. From 433d70b74bed66591a30ad3b3377c51208fb1660 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 13:51:33 +0200 Subject: [PATCH 2/2] fix(control): the idle ceiling label counts the battery order on both sides The fuse's room now holds our unshown battery order, so the hourly idle start's label compared the ceiling's room without it against the fuse's room with it, and a car the ceiling held read fuse_limit. Both sides now count the order, as the start test itself does. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_fuse_budget.go | 3 ++- go/internal/control/ev_fuse_budget_test.go | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 7e12d2df..c9f194f2 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -181,6 +181,7 @@ type chargerOffers struct { // each EV offer sent above what the reading may show, until its charger // can have applied it. A single-phase offer may land on any phase. Rises // and starts count it on top of the meter; a cut never waits for it. +// stepEVMeterLimit adds our unshown battery orders (withBatteryOrders). type pendingLoad struct { w float64 // in total import phaseA [3]float64 // on each phase @@ -501,7 +502,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first } else { // The start test below holds an idle car to the ceiling itself, // whatever slack the hour has: name it by this reading. - lim.byImport = r.startLevelW-gridW < r.fuseW + lim.byImport = r.startLevelW-gridW-pending < r.fuseW } room := 0.0 if fresh { diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 1a5233d9..add26c76 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -780,6 +780,28 @@ func TestEVHardLimitBatteryOrderCountsOnEachPhase(t *testing.T) { } } +// While the hour's import ceiling holds an idle car back, the label names +// the ceiling. The fuse's room counts our battery order on its way, so the +// ceiling's room in the comparison must count it too, or a 5,500 W order +// with 2,750 W of fuse room read fuse_limit from the second reading on. +func TestEVHardLimitIdleCeilingLabelCountsBatteryOrder(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 9000, []float64{13, 13, 13}, 0, "3p") + now := time.Now() + state.clock = func() time.Time { return now } + state.PeakImportCeilingW = 12000 + state.importHour = hourImport{start: state.importHourStart(now), ceilingW: 12000, limitW: 12000, riseW: 12000, + changedAt: now, seen: true, seenCeiling: 12000, lastAt: now.Add(-time.Second)} + state.batteryCommands = map[string]commandLog{"battery": commandLog{}.add(now, 5500)} + for reading := 1; reading <= 2; reading++ { + store.Get("meter", telemetry.DerMeter).UpdatedAt = now + got, _ := EVFuseBudget(store, state, points, configs, 17250) + if reason := EVLimitReason(state); got != 0 || reason != "import_limit" { + t.Fatalf("reading %d: cap %.1f W %s, want 0 W import_limit", reading, got, reason) + } + now = now.Add(2 * time.Second) + } +} + // A lone 0 W or unavailable charger reading is not a reason to cut, and // before our first command a charging car's reading earns no credit: the // meter alone decides.