diff --git a/core/loadpoint.go b/core/loadpoint.go index f5cb51eea0..918251aed8 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -97,7 +97,7 @@ type LoadPoint struct { Mode api.ChargeMode `mapstructure:"mode"` // Charge mode, guarded by mutex Title string `mapstructure:"title"` // UI title - Phases int `mapstructure:"phases"` // Charger enabled phases + DefaultPhases int `mapstructure:"phases"` // Charger enabled phases ChargerRef string `mapstructure:"charger"` // Charger reference VehicleRef string `mapstructure:"vehicle"` // Vehicle reference VehiclesRef []string `mapstructure:"vehicles"` // Vehicles reference @@ -112,6 +112,7 @@ type LoadPoint struct { GuardDuration time.Duration // charger enable/disable minimum holding time enabled bool // Charger enabled state + phases int // Charger active phases, guarded by mutex measuredPhases int // Charger physically measured phases chargeCurrent float64 // Charger current limit guardUpdated time.Time // Charger enabled/disabled timestamp @@ -213,9 +214,10 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin lp.charger = cp.Charger(lp.ChargerRef) lp.configureChargerType(lp.charger) - // TODO handle delayed scale-down + // setup fixed phases if _, ok := lp.charger.(api.ChargePhases); ok && lp.GetPhases() != 0 { - lp.log.WARN.Printf("ignoring phases config (%dp) for switchable charger", lp.GetPhases()) + lp.log.WARN.Printf("locking phase config to %dp for switchable charger", lp.GetPhases()) + // set to unknown since we don't know the charger's setting yet and don't want to interrupt charging lp.setPhases(0) } @@ -240,7 +242,7 @@ func NewLoadPoint(log *util.Logger) *LoadPoint { clock: clock, // mockable time bus: bus, // event bus Mode: api.ModeOff, - Phases: 3, + phases: 3, status: api.StatusNone, MinCurrent: 6, // A MaxCurrent: 16, // A @@ -512,7 +514,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even lp.publish("title", lp.Title) lp.publish("minCurrent", lp.MinCurrent) lp.publish("maxCurrent", lp.MaxCurrent) - lp.publish("phases", lp.Phases) + lp.publish("phases", lp.phases) lp.publish("activePhases", lp.activePhases()) lp.publish("hasVehicle", len(lp.vehicles) > 0) @@ -975,6 +977,10 @@ func (lp *LoadPoint) resetPVTimerIfRunning(typ ...string) { // scalePhasesIfAvailable scales if api.ChargePhases is available func (lp *LoadPoint) scalePhasesIfAvailable(phases int) error { + if lp.DefaultPhases != 0 { + phases = lp.DefaultPhases + } + if _, ok := lp.charger.(api.ChargePhases); ok { return lp.scalePhases(phases) } @@ -986,11 +992,11 @@ func (lp *LoadPoint) scalePhasesIfAvailable(phases int) error { func (lp *LoadPoint) setPhases(phases int) { if lp.GetPhases() != phases { lp.Lock() - lp.Phases = phases + lp.phases = phases lp.phaseTimer = time.Time{} lp.Unlock() - lp.publish("phases", lp.Phases) + lp.publish("phases", lp.phases) lp.publishTimer(phaseTimer, 0, timerInactive) lp.resetMeasuredPhases() @@ -1043,7 +1049,7 @@ func (lp *LoadPoint) pvScalePhases(availablePower, minCurrent, maxCurrent float6 activePhases := lp.activePhases() // scale down phases - if targetCurrent := powerToCurrent(availablePower, activePhases); targetCurrent < minCurrent && activePhases > 1 { + if targetCurrent := powerToCurrent(availablePower, activePhases); targetCurrent < minCurrent && activePhases > 1 && lp.DefaultPhases < 3 { lp.log.DEBUG.Printf("available power %.0fW < %.0fW min %dp threshold", availablePower, float64(activePhases)*Voltage*minCurrent, activePhases) if lp.phaseTimer.IsZero() { diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 0a73b0ac49..3accbe87f7 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -102,16 +102,21 @@ func (lp *LoadPoint) SetMinSoC(soc int) { func (lp *LoadPoint) GetPhases() int { lp.Lock() defer lp.Unlock() - return lp.Phases + return lp.phases } // SetPhases sets loadpoint enabled phases func (lp *LoadPoint) SetPhases(phases int) error { - if phases != 1 && phases != 3 { + // limit auto mode (phases=0) to scalable charger + if _, ok := lp.charger.(api.ChargePhases); !ok && phases == 0 { return fmt.Errorf("invalid number of phases: %d", phases) } - if _, ok := lp.charger.(api.ChargePhases); ok { + if phases != 0 && phases != 1 && phases != 3 { + return fmt.Errorf("invalid number of phases: %d", phases) + } + + if _, ok := lp.charger.(api.ChargePhases); ok && phases > 0 { return lp.scalePhases(phases) } diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go index cf4f8ba379..dacdf32969 100644 --- a/core/loadpoint_phases.go +++ b/core/loadpoint_phases.go @@ -63,9 +63,12 @@ func (lp *LoadPoint) maxActivePhases() int { measured = 0 } - // if 1p3p supported then assume 3p + // if 1p3p supported then assume configured limit or 3p if _, ok := lp.charger.(api.ChargePhases); ok { - physical = 3 + physical = lp.DefaultPhases + if physical == 0 { + physical = 3 + } } return min(expect(vehicle), expect(physical), expect(measured)) diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go index 4ed9f8ef99..5979b638a0 100644 --- a/core/loadpoint_phases_test.go +++ b/core/loadpoint_phases_test.go @@ -65,6 +65,66 @@ var ( } ) +func TestMaxActivePhases(t *testing.T) { + ctrl := gomock.NewController(t) + + // 0 is auto, 1/3 are fixed + for _, dflt := range []int{0, 1, 3} { + for _, tc := range phaseTests { + // skip invalid configs (free scaling for simple charger) + if dflt == 0 && tc.capable != 0 { + continue + } + + t.Log(dflt, tc) + + plainCharger := mock.NewMockCharger(ctrl) + + // 1p3p + var phaseCharger *mock.MockChargePhases + if tc.capable == 0 { + phaseCharger = mock.NewMockChargePhases(ctrl) + } + + vehicle := mock.NewMockVehicle(ctrl) + vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1) + + lp := &LoadPoint{ + DefaultPhases: dflt, // fixed phases or default + vehicle: vehicle, + phases: tc.physical, + measuredPhases: tc.measuredPhases, + } + + if phaseCharger != nil { + lp.charger = struct { + *mock.MockCharger + *mock.MockChargePhases + }{ + plainCharger, phaseCharger, + } + } else { + lp.charger = struct { + *mock.MockCharger + }{ + plainCharger, + } + } + + expectedPhases := tc.maxExpected + + // restrict scalable charger by config + if tc.capable == 0 && dflt > 0 && dflt < tc.maxExpected { + expectedPhases = dflt + } + + if phs := lp.maxActivePhases(); phs != expectedPhases { + t.Errorf("expected max %d, got %d", expectedPhases, phs) + } + } + } +} + func testScale(t *testing.T, lp *LoadPoint, power float64, direction string, tc testCase) { act := lp.activePhases() max := lp.maxActivePhases() @@ -118,19 +178,20 @@ func TestPvScalePhases(t *testing.T) { vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1) lp := &LoadPoint{ - log: util.NewLogger("foo"), - bus: evbus.New(), - clock: clock, - chargeMeter: &Null{}, // silence nil panics - chargeRater: &Null{}, // silence nil panics - chargeTimer: &Null{}, // silence nil panics - progress: NewProgress(0, 10), // silence nil panics - wakeUpTimer: NewTimer(), // silence nil panics - Mode: api.ModeNow, - MinCurrent: minA, - MaxCurrent: maxA, - vehicle: vehicle, - Phases: tc.physical, + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock, + chargeMeter: &Null{}, // silence nil panics + chargeRater: &Null{}, // silence nil panics + chargeTimer: &Null{}, // silence nil panics + progress: NewProgress(0, 10), // silence nil panics + wakeUpTimer: NewTimer(), // silence nil panics + Mode: api.ModeNow, + MinCurrent: minA, + MaxCurrent: maxA, + vehicle: vehicle, + DefaultPhases: 0, // allow switching + phases: tc.physical, } if phaseCharger != nil { @@ -155,8 +216,8 @@ func TestPvScalePhases(t *testing.T) { t.Fatalf("%v invalid test case", tc) } - if lp.Phases != tc.physical { - t.Error("wrong phases", lp.Phases, tc.physical) + if lp.phases != tc.physical { + t.Error("wrong phases", lp.phases, tc.physical) } if phs := lp.activePhases(); phs != tc.actExpected { @@ -184,7 +245,7 @@ func TestPvScalePhases(t *testing.T) { lp.phaseTimer = time.Time{} // reset to initial state - lp.Phases = tc.physical + lp.phases = tc.physical lp.measuredPhases = tc.measuredPhases plainCharger.EXPECT().Enable(false).Return(nil).MaxTimes(1) @@ -289,7 +350,7 @@ func TestPvScalePhasesTimer(t *testing.T) { charger: charger, MinCurrent: minA, MaxCurrent: maxA, - Phases: tc.phases, + phases: tc.phases, measuredPhases: tc.measuredPhases, Enable: ThresholdConfig{ Delay: dt, @@ -312,8 +373,57 @@ func TestPvScalePhasesTimer(t *testing.T) { switch { case tc.res != res: t.Errorf("expected %v, got %v", tc.res, res) - case lp.Phases != tc.toPhases: - t.Errorf("expected %dp, got %dp", tc.toPhases, lp.Phases) + case lp.phases != tc.toPhases: + t.Errorf("expected %dp, got %dp", tc.toPhases, lp.phases) } } } + +func TestScalePhasesIfAvailable(t *testing.T) { + ctrl := gomock.NewController(t) + + tc := []struct { + dflt, physical, maxExpected int + }{ + {0, 0, 3}, + {0, 1, 3}, + {0, 3, 3}, + {1, 0, 1}, + {1, 1, 1}, + {1, 3, 1}, + {3, 0, 3}, + {3, 1, 3}, + {3, 3, 3}, + } + + for _, tc := range tc { + t.Log(tc) + + plainCharger := mock.NewMockCharger(ctrl) + phaseCharger := mock.NewMockChargePhases(ctrl) + + lp := &LoadPoint{ + log: util.NewLogger("foo"), + clock: clock.NewMock(), + charger: struct { + *mock.MockCharger + *mock.MockChargePhases + }{ + plainCharger, + phaseCharger, + }, + MinCurrent: minA, + DefaultPhases: tc.dflt, // fixed phases or default + phases: tc.physical, // current phase status + } + + // restrict scalable charger by config + if tc.dflt == 0 || tc.dflt != tc.physical { + phaseCharger.EXPECT().Phases1p3p(tc.maxExpected).Return(nil) + } + + _ = lp.scalePhasesIfAvailable(3) + + ctrl.Finish() + } +} diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 16464b22e5..300eea2275 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -71,8 +71,8 @@ func attachListeners(t *testing.T, lp *LoadPoint) { func TestNew(t *testing.T) { lp := NewLoadPoint(util.NewLogger("foo")) - if lp.Phases != 3 { - t.Errorf("Phases %v", lp.Phases) + if lp.phases != 3 { + t.Errorf("Phases %v", lp.phases) } if lp.MinCurrent != minA { t.Errorf("MinCurrent %v", lp.MinCurrent) @@ -154,7 +154,7 @@ func TestUpdatePowerZero(t *testing.T) { wakeUpTimer: NewTimer(), MinCurrent: minA, MaxCurrent: maxA, - Phases: 1, + phases: 1, status: tc.status, // no status change } @@ -302,7 +302,7 @@ func TestPVHysteresis(t *testing.T) { charger: charger, MinCurrent: minA, MaxCurrent: maxA, - Phases: phases, + phases: phases, measuredPhases: phases, Enable: ThresholdConfig{ Threshold: tc.enable, @@ -350,7 +350,7 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) { clock: clck, MinCurrent: minA, MaxCurrent: maxA, - Phases: phases, + phases: phases, measuredPhases: phases, }