From 00e97e51c4e1732edb187c88f118c86e1b9321c9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 13:36:03 +0200 Subject: [PATCH] fix(control): a down battery's PV no longer takes part of the charge target splitByCapacityAndPV summed the PV of every inverter group, including a group with no battery in the split (offline, faulted or charge-blocked). That group's local share went to no battery, so the fleet charged up to that PV short. Golden seeded_siblings/039 charged 6,284 W of 9,600 W. Count only groups with a battery in the split. Their PV reaches the other batteries over the AC bus, which split the whole target by capacity. Re-record seeded_siblings: 8 device_fault records move. Co-Authored-By: Claude Opus 5.5 --- .changeset/down-group-pv-share.md | 5 + go/internal/control/control_test.go | 37 ++++++ go/internal/control/dispatch.go | 17 +-- .../testdata/golden/seeded_siblings.json | 108 +++++++++--------- 4 files changed, 107 insertions(+), 60 deletions(-) create mode 100644 .changeset/down-group-pv-share.md diff --git a/.changeset/down-group-pv-share.md b/.changeset/down-group-pv-share.md new file mode 100644 index 000000000..0a83a386c --- /dev/null +++ b/.changeset/down-group-pv-share.md @@ -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. diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index e3de918f2..44ab142ed 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -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. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index a2cc95830..388e73afc 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -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 @@ -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) @@ -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 diff --git a/go/internal/control/testdata/golden/seeded_siblings.json b/go/internal/control/testdata/golden/seeded_siblings.json index f14522be2..e6339aa3a 100644 --- a/go/internal/control/testdata/golden/seeded_siblings.json +++ b/go/internal/control/testdata/golden/seeded_siblings.json @@ -1,7 +1,7 @@ { "family": "seeded_siblings", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "b3af71859a912dcae3a512f6c1ca79a21c2bb631", "record_count": 60, "records": [ { @@ -396,12 +396,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 0, + "target_w": 1303.9, "clamped": false }, { "driver": "sungrow", - "target_w": 0, + "target_w": 1303.9, "clamped": false } ], @@ -424,9 +424,9 @@ }, "site_totals": { "battery_current_w": -13, - "battery_target_sum_w": 0, + "battery_target_sum_w": 2607.8, "raw_grid_w": -4368, - "projected_grid_w": -4355 + "projected_grid_w": -1747.1999999999998 } }, { @@ -1060,17 +1060,19 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 2568.0000044676126, + "target_w": 3716.1290322580644, "clamped": false }, { "driver": "sungrow", - "target_w": 4066.0000070737206, - "clamped": false + "target_w": 5000, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, @@ -1088,9 +1090,9 @@ }, "site_totals": { "battery_current_w": 850, - "battery_target_sum_w": 6634.000011541333, + "battery_target_sum_w": 8716.129032258064, "raw_grid_w": -5208, - "projected_grid_w": 576.0000115413332 + "projected_grid_w": 2658.1290322580644 } }, { @@ -1714,12 +1716,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 402.6, + "target_w": 1459.9333333333334, "clamped": false }, { "driver": "sungrow", - "target_w": 805.2, + "target_w": 2919.866666666667, "clamped": false } ], @@ -1742,9 +1744,9 @@ }, "site_totals": { "battery_current_w": 1639, - "battery_target_sum_w": 1207.8000000000002, + "battery_target_sum_w": 4379.8, "raw_grid_w": -4568, - "projected_grid_w": -4999.2 + "projected_grid_w": -1827.1999999999998 } }, { @@ -2162,7 +2164,7 @@ }, { "driver": "sungrow", - "target_w": 3287.6712395762556, + "target_w": 3287.671232876712, "clamped": false } ], @@ -2188,9 +2190,9 @@ }, "site_totals": { "battery_current_w": -479, - "battery_target_sum_w": 8287.671239576255, + "battery_target_sum_w": 8287.671232876712, "raw_grid_w": -5443, - "projected_grid_w": 3323.671239576255 + "projected_grid_w": 3323.6712328767117 } }, { @@ -2365,12 +2367,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 1122.2709677419355, + "target_w": 2943.5612903225806, "clamped": false }, { "driver": "sungrow", - "target_w": 1776.9290322580644, + "target_w": 4660.63870967742, "clamped": false } ], @@ -2393,9 +2395,9 @@ }, "site_totals": { "battery_current_w": 4063, - "battery_target_sum_w": 2899.2, + "battery_target_sum_w": 7604.200000000001, "raw_grid_w": -5902, - "projected_grid_w": -7065.8 + "projected_grid_w": -2360.7999999999993 } }, { @@ -3016,12 +3018,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 0, + "target_w": 196.83168316831683, "clamped": false }, { "driver": "sungrow", - "target_w": 0, + "target_w": 598.3683168316832, "clamped": false } ], @@ -3044,9 +3046,9 @@ }, "site_totals": { "battery_current_w": -1063, - "battery_target_sum_w": 0, + "battery_target_sum_w": 795.2, "raw_grid_w": -3097, - "projected_grid_w": -2034 + "projected_grid_w": -1238.8 } }, { @@ -3237,12 +3239,12 @@ }, { "driver": "pixii", - "target_w": 3243.243246846847, + "target_w": 3243.2432432432433, "clamped": false }, { "driver": "sungrow", - "target_w": 3113.5135169729733, + "target_w": 3113.5135135135138, "clamped": false } ], @@ -3267,9 +3269,9 @@ }, "site_totals": { "battery_current_w": -4513, - "battery_target_sum_w": 6656.75676381982, + "battery_target_sum_w": 6656.756756756757, "raw_grid_w": -5457, - "projected_grid_w": 5712.75676381982 + "projected_grid_w": 5712.756756756757 } }, { @@ -3664,12 +3666,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 0, + "target_w": 729.5049504950495, "clamped": false }, { "driver": "sungrow", - "target_w": 0, + "target_w": 2217.6950495049505, "clamped": false } ], @@ -3692,9 +3694,9 @@ }, "site_totals": { "battery_current_w": 627, - "battery_target_sum_w": 0, + "battery_target_sum_w": 2947.2, "raw_grid_w": -3867, - "projected_grid_w": -4494 + "projected_grid_w": -1546.8000000000002 } }, { @@ -3772,7 +3774,7 @@ }, { "driver": "sungrow", - "target_w": -3200.0000029653334, + "target_w": -3200, "clamped": false } ], @@ -3797,9 +3799,9 @@ }, "site_totals": { "battery_current_w": -1969, - "battery_target_sum_w": -3200.0000029653334, + "battery_target_sum_w": -3200, "raw_grid_w": 5401, - "projected_grid_w": 4169.999997034667 + "projected_grid_w": 4170 } }, { @@ -4324,12 +4326,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 3142.0000053333333, + "target_w": 4800, "clamped": false }, { "driver": "sungrow", - "target_w": 3142.0000053333333, + "target_w": 4800, "clamped": false } ], @@ -4352,9 +4354,9 @@ }, "site_totals": { "battery_current_w": -3448, - "battery_target_sum_w": 6284.000010666667, + "battery_target_sum_w": 9600, "raw_grid_w": -5478, - "projected_grid_w": 4254.000010666667 + "projected_grid_w": 7570 } }, { @@ -4976,12 +4978,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 1670.3000000000002, + "target_w": 3996.8, "clamped": false }, { "driver": "sungrow", - "target_w": 1670.3000000000002, + "target_w": 3996.8, "clamped": false } ], @@ -5004,9 +5006,9 @@ }, "site_totals": { "battery_current_w": 5215, - "battery_target_sum_w": 3340.6000000000004, + "battery_target_sum_w": 7993.6, "raw_grid_w": -4631, - "projected_grid_w": -6505.4 + "projected_grid_w": -1852.3999999999996 } }, { @@ -5419,12 +5421,12 @@ }, { "driver": "pixii", - "target_w": 4800.000008448, + "target_w": 4800, "clamped": false }, { "driver": "sungrow", - "target_w": 4800.000008448, + "target_w": 4800, "clamped": false } ], @@ -5449,9 +5451,9 @@ }, "site_totals": { "battery_current_w": -1477, - "battery_target_sum_w": 9600.000016896, + "battery_target_sum_w": 9600, "raw_grid_w": -3251, - "projected_grid_w": 7826.000016896 + "projected_grid_w": 7826 } }, { @@ -6501,12 +6503,12 @@ }, { "driver": "pixii", - "target_w": 1920.0000032, + "target_w": 1920, "clamped": false }, { "driver": "sungrow", - "target_w": 3840.0000064, + "target_w": 3840, "clamped": false } ], @@ -6531,9 +6533,9 @@ }, "site_totals": { "battery_current_w": -3067, - "battery_target_sum_w": 6060.000009599999, + "battery_target_sum_w": 6060, "raw_grid_w": -3266, - "projected_grid_w": 5861.000009599999 + "projected_grid_w": 5861 } } ]