diff --git a/core/site.go b/core/site.go index 1c9465d60..0e19a5dc2 100644 --- a/core/site.go +++ b/core/site.go @@ -668,21 +668,27 @@ func (site *Site) updateBatteryMeters() { mm[i].Controllable = new(controllable) } - var batterySocAcc float64 - var totalCapacity float64 - - if lo.SomeBy(mm, func(m types.Measurement) bool { return m.Capacity == nil || *m.Capacity <= 0 }) { - // any capacity is missing - batterySocAcc = sumOfSocs(mm) - totalCapacity = float64(len(site.batteryMeters)) + // retain the last known soc when every battery read failed this cycle, so a + // transient meter error does not report the pack as empty (0%) + if lo.EveryBy(mm, func(m types.Measurement) bool { return m.Soc == nil }) { + site.log.WARN.Printf("battery soc: read failed, keeping last %.0f%%", site.battery.Soc) } else { - // all capacities available - weigh soc by capacity - batterySocAcc = weightedSumOfSocs(mm) - totalCapacity = lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity }) - } + var batterySocAcc float64 + var totalCapacity float64 - site.battery.Soc = math.Min(100, batterySocAcc/totalCapacity) - site.battery.Capacity = totalCapacity + if lo.SomeBy(mm, func(m types.Measurement) bool { return m.Capacity == nil || *m.Capacity <= 0 }) { + // any capacity is missing + batterySocAcc = sumOfSocs(mm) + totalCapacity = float64(len(site.batteryMeters)) + } else { + // all capacities available - weigh soc by capacity + batterySocAcc = weightedSumOfSocs(mm) + totalCapacity = lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity }) + } + + site.battery.Soc = math.Min(100, batterySocAcc/totalCapacity) + site.battery.Capacity = totalCapacity + } site.battery.Power = lo.SumBy(mm, func(m types.Measurement) float64 { return m.Power diff --git a/core/site_battery_test.go b/core/site_battery_test.go index 30860b7f9..fb397a317 100644 --- a/core/site_battery_test.go +++ b/core/site_battery_test.go @@ -1,6 +1,7 @@ package core import ( + "errors" "testing" "time" @@ -11,6 +12,36 @@ import ( "go.uber.org/mock/gomock" ) +// TestBatterySocRetainOnReadError guards that a failed soc read keeps the last +// known soc instead of reporting the pack as empty (discussion #26560). +func TestBatterySocRetainOnReadError(t *testing.T) { + ctrl := gomock.NewController(t) + + meter := api.NewMockMeter(ctrl) + meter.EXPECT().CurrentPower().Return(0.0, nil).AnyTimes() + + battery := api.NewMockBattery(ctrl) + battery.EXPECT().Soc().Return(0.0, errors.New("read failed")).AnyTimes() + + var bat api.Meter = &struct { + api.Meter + api.Battery + }{ + Meter: meter, + Battery: battery, + } + + site := &Site{ + log: util.NewLogger("foo"), + batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, bat)}, + } + site.battery.Soc = 84 + + site.updateBatteryMeters() + + assert.Equal(t, 84.0, site.battery.Soc, "soc retained when the read fails") +} + func TestApplyBatteryMode(t *testing.T) { for _, tc := range []struct { internal, expected api.BatteryMode