Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-one-phase-battery-order.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 24 additions & 6 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions go/internal/control/ev_fuse_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading