package core import ( "testing" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/util" optimizer "github.com/evcc-io/optimizer/client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) func TestLoadpointProfile(t *testing.T) { ctrl := gomock.NewController(t) lp := loadpoint.NewMockAPI(ctrl) lp.EXPECT().GetMode().Return(api.ModeMinPV).AnyTimes() lp.EXPECT().GetStatus().Return(api.StatusC).AnyTimes() lp.EXPECT().GetChargePower().Return(10000.0).AnyTimes() // 10 kW lp.EXPECT().EffectiveMinPower().Return(1000.0).AnyTimes() // 1 kW lp.EXPECT().GetRemainingEnergy().Return(1.8).AnyTimes() // 1.8 kWh // expected slots: 0.25 kWh... require.Equal(t, []float64{250, 250, 250, 250, 250, 250, 250, 50}, loadpointProfile(lp, 8)) } func TestAsTimestamps(t *testing.T) { // now is 10 minutes into a 15-minute slot now := time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC) // dt[0]=300 means first event is 300s (5min) before end of current slot // dt[1..] just mark subsequent slot boundaries dt := []int{60 * 5, 60 * 15, 60 * 15} got := asTimestamps(dt, now) // current slot: 12:00–12:15 // first timestamp: 12:15 - 5min = 12:10 // subsequent: 12:15, 12:30 assert.Equal(t, []time.Time{ time.Date(2025, 1, 1, 12, 10, 0, 0, time.UTC), time.Date(2025, 1, 1, 12, 15, 0, 0, time.UTC), time.Date(2025, 1, 1, 12, 30, 0, 0, time.UTC), }, got) } func TestBatteryForecastSocExtremes(t *testing.T) { for _, tc := range []struct { name string req []optimizer.BatteryConfig soc [][]float32 high, low *batteryForecastSlot }{ { "no home battery", []optimizer.BatteryConfig{{SMax: 80}}, // SCapacity unset → vehicle [][]float32{{1000, 2000}}, nil, nil, }, { "single home battery rising — reaches full", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, [][]float32{{200, 500, 1000}}, &batteryForecastSlot{slot: 2, soc: 100, limit: true}, &batteryForecastSlot{slot: 0, soc: 20, limit: false}, }, { "single home battery falling — reaches empty", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, [][]float32{{900, 500, 0}}, &batteryForecastSlot{slot: 0, soc: 90, limit: false}, &batteryForecastSlot{slot: 2, soc: 0, limit: true}, }, { "single home battery — local extremes (no limit reached)", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 900, SMin: 100}}, [][]float32{{500, 800, 200}}, &batteryForecastSlot{slot: 1, soc: 80, limit: false}, &batteryForecastSlot{slot: 2, soc: 20, limit: false}, }, { "two home batteries aggregated", []optimizer.BatteryConfig{ {SCapacity: 1000, SMax: 1000}, {SCapacity: 1000, SMax: 1000}, }, [][]float32{ {200, 400, 1000}, {800, 400, 1000}, }, &batteryForecastSlot{slot: 2, soc: 100, limit: true}, &batteryForecastSlot{slot: 1, soc: 40, limit: false}, }, { "vehicle and home battery — vehicle ignored", []optimizer.BatteryConfig{ {SMax: 80}, // vehicle {SCapacity: 1000, SMax: 1000}, // home }, [][]float32{ {0, 0, 80}, {200, 500, 900}, }, &batteryForecastSlot{slot: 2, soc: 90, limit: false}, &batteryForecastSlot{slot: 0, soc: 20, limit: false}, }, { "first slot at SMax wins for highest", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, [][]float32{{1000, 1000, 500}}, &batteryForecastSlot{slot: 0, soc: 100, limit: true}, &batteryForecastSlot{slot: 2, soc: 50, limit: false}, }, { "near SMax is not full", []optimizer.BatteryConfig{{SCapacity: 1000, SMax: 1000}}, [][]float32{{500, 999, 800}}, &batteryForecastSlot{slot: 1, soc: 99.9, limit: false}, &batteryForecastSlot{slot: 0, soc: 50, limit: false}, }, } { t.Run(tc.name, func(t *testing.T) { resp := make([]optimizer.BatteryResult, len(tc.soc)) for i, s := range tc.soc { resp[i] = optimizer.BatteryResult{StateOfCharge: s} } high, low := batteryForecastSocExtremes(tc.req, resp) if tc.high == nil { assert.Nil(t, high, "high") } else { require.NotNil(t, high, "high") assert.Equal(t, tc.high.slot, high.slot, "high.slot") assert.InDelta(t, tc.high.soc, high.soc, 1e-3, "high.soc") assert.Equal(t, tc.high.limit, high.limit, "high.limit") } if tc.low == nil { assert.Nil(t, low, "low") } else { require.NotNil(t, low, "low") assert.Equal(t, tc.low.slot, low.slot, "low.slot") assert.InDelta(t, tc.low.soc, low.soc, 1e-3, "low.soc") assert.Equal(t, tc.low.limit, low.limit, "low.limit") } }) } } func TestOptimizerChargingStrategy(t *testing.T) { site := &Site{log: util.NewLogger("foo")} // default when unset assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy()) // invalid value rejected, strategy unchanged require.Error(t, site.SetOptimizerChargingStrategy("bogus")) assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy()) // valid change is applied (re-trigger is gated on sponsor/enabled, not unit-tested here) require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks))) assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy()) }