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..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 @@ -389,7 +390,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 +469,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) @@ -499,11 +502,11 @@ 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 { - 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 +605,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..add26c76 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -751,6 +751,57 @@ 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) + } + }) + } +} + +// 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.