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/down-group-pv-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Batteries now take the whole charge FTW asks for when another battery is down. On a site with inverter groups, the PV of an inverter whose battery is offline, faulted or cannot charge was still counted as charge for that battery, and that part of the target went to no battery. With 3.3 kW of PV on the down battery's inverter and a 9.6 kW charge target, the other two batteries got 6.3 kW in total; when the target was smaller than that PV, they got nothing and the surplus went to the grid. That PV now counts as power on the AC bus, so the other batteries share the whole target by capacity, within their limits.
37 changes: 37 additions & 0 deletions go/internal/control/control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3515,6 +3515,43 @@ func TestInverterAffinity_FallsBackToProportionalOnOverflow(t *testing.T) {
}
}

// PV on an inverter whose battery cannot charge is not DC-local to any
// battery that can. It reaches them over the AC bus, so the fleet total
// is split by capacity and nothing is lost. Before, that group's PV share
// of the total went to no battery (golden seeded_siblings/039 lost 3,316 W
// of a 9,600 W target).
func TestInverterAffinity_PVWithoutItsBatteryKeepsTheTotal(t *testing.T) {
online := []batteryInfo{
{driver: "sungrow", capacityWh: 15200, soc: 0.5, online: true, group: "sg"},
{driver: "pixii", capacityWh: 15200, soc: 0.5, online: true, group: "px"},
}
blocked := append([]batteryInfo{
{driver: "ferroamp", capacityWh: 10000, soc: 0.5, online: true, group: "fa", chargeBlocked: true},
}, online...)
for _, tc := range []struct {
name string
bats []batteryInfo
targetW float64
wantEach float64
}{
{"battery offline", online, 6000, 3000},
{"battery cannot charge", blocked, 6000, 3000},
// Below the down group's PV, master gave the whole target to it: 0 W each.
{"target below that PV", online, 2000, 1000},
} {
t.Run(tc.name, func(t *testing.T) {
groupPV := map[string]float64{"fa": 3316}
got := map[string]float64{}
for _, tg := range distributeProportional(tc.bats, tc.targetW, groupPV) {
got[tg.Driver] = tg.TargetW
}
if math.Abs(got["sungrow"]-tc.wantEach) > 1 || math.Abs(got["pixii"]-tc.wantEach) > 1 || got["ferroamp"] != 0 {
t.Errorf("targets = %v, want sungrow and pixii %.0f each, ferroamp 0", got, tc.wantEach)
}
})
}
}

// With no inverter groups configured, the algorithm must produce
// identical results to today's capacity-proportional split — the
// backward-compat invariant.
Expand Down
17 changes: 10 additions & 7 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3087,8 +3087,10 @@ func distributeProportional(bats []batteryInfo, totalCorrection float64, groupPV
// same split over a subset of batteries.
func splitByCapacityAndPV(bats []batteryInfo, desiredTotal float64, groupPV map[string]float64) []DispatchTarget {
var totalCap float64
capByGroup := map[string]float64{}
for _, b := range bats {
totalCap += b.capacityWh
capByGroup[b.group] += b.capacityWh
}
if totalCap <= 0 {
return nil
Expand All @@ -3097,9 +3099,15 @@ func splitByCapacityAndPV(bats []batteryInfo, desiredTotal float64, groupPV map[
// Discharge, idle, or no PV locality info → capacity-only split.
// Discharge energy flows to the AC bus regardless of where it
// originated, so DC-locality has no win for the negative branch.
// PV in a group with no battery here (offline, faulted or unable to
// charge) is not DC-local to any of them; it reaches them over the
// AC bus, so it stays out of the local pool. Counting it would hand
// its share of the total to no battery.
var totalPV float64
for _, w := range groupPV {
totalPV += w
for g, w := range groupPV {
if capByGroup[g] > 0 {
totalPV += w
}
}
if desiredTotal <= 0 || totalPV <= 0 {
return distributeByCapacity(bats, desiredTotal, totalCap)
Expand All @@ -3118,11 +3126,6 @@ func splitByCapacityAndPV(bats []batteryInfo, desiredTotal float64, groupPV map[
localCap := math.Min(desiredTotal, totalPV)
overflow := desiredTotal - localCap

capByGroup := map[string]float64{}
for _, b := range bats {
capByGroup[b.group] += b.capacityWh
}

out := make([]DispatchTarget, 0, len(bats))
for _, b := range bats {
var localShare float64
Expand Down
Loading
Loading