@@ -699,7 +699,7 @@ func TestDecideBatteryAction_DischargeNegativeExportUsesMode2(t *testing.T) {
699699 BatteryChargeFromGrid : 0 ,
700700 BatteryChargeFromPV : 0 ,
701701 }
702- action := decideBatteryAction (decision , 5.0 , 0.0 )
702+ action := decideBatteryAction (decision , 5.0 , 0.0 , 0 )
703703 if action .mode != 2 {
704704 t .Errorf ("expected mode 2, got %d" , action .mode )
705705 }
@@ -715,7 +715,7 @@ func TestDecideBatteryAction_DischargeZeroExportUsesMode2(t *testing.T) {
715715 BatteryChargeFromGrid : 0 ,
716716 BatteryChargeFromPV : 0 ,
717717 }
718- action := decideBatteryAction (decision , 5.0 , 0.0 )
718+ action := decideBatteryAction (decision , 5.0 , 0.0 , 0 )
719719 if action .mode != 2 {
720720 t .Errorf ("expected mode 2, got %d" , action .mode )
721721 }
@@ -734,7 +734,7 @@ func TestDecideBatteryAction_DischargeWithPlannedGridExportUsesMode5(t *testing.
734734 BatteryChargeFromGrid : 0 ,
735735 BatteryChargeFromPV : 0 ,
736736 }
737- action := decideBatteryAction (decision , 5.0 , 0.0 )
737+ action := decideBatteryAction (decision , 5.0 , 0.0 , 0 )
738738 if action .mode != 5 {
739739 t .Errorf ("expected mode 5, got %d" , action .mode )
740740 }
@@ -764,7 +764,7 @@ func TestDecideBatteryAction_DefaultUsesSelfConsumptionNotIdle(t *testing.T) {
764764 GridImport : 5.0 ,
765765 GridExport : 0 ,
766766 }
767- action := decideBatteryAction (decision , 10.0 , 0.0 )
767+ action := decideBatteryAction (decision , 10.0 , 0.0 , 0 )
768768
769769 if action .mode != 2 {
770770 t .Errorf ("expected mode 2 (self-consumption), got %d" , action .mode )
@@ -783,6 +783,81 @@ func TestDecideBatteryAction_DefaultUsesSelfConsumptionNotIdle(t *testing.T) {
783783 }
784784}
785785
786+ // TestDecideBatteryAction_DefaultCapsChargeAtBalancingThresholdWhenNotNeeded
787+ // verifies that when the battery is already at/above the balancing SOC
788+ // threshold and this optimisation run did not call for weekly cell-balancing,
789+ // the opportunistic self-consumption charge limit is capped at 0 instead of
790+ // maxCharge — preventing the battery from being pushed toward 100% (and into
791+ // its low-efficiency CV/balancing phase) every time PV surplus is available.
792+ func TestDecideBatteryAction_DefaultCapsChargeAtBalancingThresholdWhenNotNeeded (t * testing.T ) {
793+ decision := & mpc.ControlDecision {
794+ BatteryChargeFromPV : 0 ,
795+ BatteryChargeFromGrid : 0 ,
796+ BatteryDischarge : 0 ,
797+ GridImport : 0 ,
798+ GridExport : 10.9 ,
799+ BatterySOC : 0.999 , // 99.9%
800+ BalancingNeeded : false ,
801+ }
802+ action := decideBatteryAction (decision , 20.0 , 0.0 , 0.998 )
803+
804+ if action .mode != 2 {
805+ t .Errorf ("expected mode 2 (self-consumption), got %d" , action .mode )
806+ }
807+ if action .chargeLimit != 0 {
808+ t .Errorf ("expected chargeLimit capped at 0, got %.2f" , action .chargeLimit )
809+ }
810+ if action .dischargeLimit != 0 {
811+ t .Errorf ("expected dischargeLimit 0, got %.2f" , action .dischargeLimit )
812+ }
813+ }
814+
815+ // TestDecideBatteryAction_DefaultAllowsChargeAtThresholdWhenBalancingNeeded
816+ // verifies that the cap above does NOT apply when this optimisation run did
817+ // call for weekly cell-balancing — the battery should still be allowed to
818+ // reach BatteryMaxSOC in that case.
819+ func TestDecideBatteryAction_DefaultAllowsChargeAtThresholdWhenBalancingNeeded (t * testing.T ) {
820+ decision := & mpc.ControlDecision {
821+ BatterySOC : 0.999 ,
822+ BalancingNeeded : true ,
823+ }
824+ action := decideBatteryAction (decision , 20.0 , 0.0 , 0.998 )
825+
826+ if action .chargeLimit != 20.0 {
827+ t .Errorf ("expected chargeLimit raised to maxCharge 20.0 when balancing is needed, got %.2f" , action .chargeLimit )
828+ }
829+ }
830+
831+ // TestDecideBatteryAction_DefaultAllowsChargeBelowThreshold verifies that the
832+ // cap only applies once SOC reaches the balancing threshold — below it, the
833+ // battery should still opportunistically absorb PV surplus up to maxCharge.
834+ func TestDecideBatteryAction_DefaultAllowsChargeBelowThreshold (t * testing.T ) {
835+ decision := & mpc.ControlDecision {
836+ BatterySOC : 0.95 ,
837+ BalancingNeeded : false ,
838+ }
839+ action := decideBatteryAction (decision , 20.0 , 0.0 , 0.998 )
840+
841+ if action .chargeLimit != 20.0 {
842+ t .Errorf ("expected chargeLimit raised to maxCharge 20.0 below threshold, got %.2f" , action .chargeLimit )
843+ }
844+ }
845+
846+ // TestDecideBatteryAction_DefaultThresholdDisabled verifies that passing 0 for
847+ // balancingSOCThreshold disables the guard entirely (e.g. when the balancing
848+ // feature is disabled in config), regardless of SOC.
849+ func TestDecideBatteryAction_DefaultThresholdDisabled (t * testing.T ) {
850+ decision := & mpc.ControlDecision {
851+ BatterySOC : 0.999 ,
852+ BalancingNeeded : false ,
853+ }
854+ action := decideBatteryAction (decision , 20.0 , 0.0 , 0 )
855+
856+ if action .chargeLimit != 20.0 {
857+ t .Errorf ("expected chargeLimit raised to maxCharge 20.0 when threshold guard is disabled, got %.2f" , action .chargeLimit )
858+ }
859+ }
860+
786861// ---------------------------------------------------------------------------
787862// PV recovery gate tests (grid charging suppression when cloud clears)
788863// ---------------------------------------------------------------------------
@@ -797,7 +872,7 @@ func TestDecideBatteryAction_GridCharge_GateSuppressesWhenPVCoversAll(t *testing
797872 LoadForecast : 4.0 ,
798873 }
799874 // recentAvgPV = 9.0 >= load(4.0) + charge(5.0) → gate fires
800- action := decideBatteryAction (decision , 12.0 , 9.0 )
875+ action := decideBatteryAction (decision , 12.0 , 9.0 , 0 )
801876
802877 if action .mode != 2 {
803878 t .Errorf ("expected mode 2 (PV-only) after gate, got %d" , action .mode )
@@ -821,7 +896,7 @@ func TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenPVInsufficient(t *tes
821896 LoadForecast : 4.0 ,
822897 }
823898 // recentAvgPV = 8.9 < load(4.0) + charge(5.0) = 9.0 → gate must NOT fire
824- action := decideBatteryAction (decision , 12.0 , 8.9 )
899+ action := decideBatteryAction (decision , 12.0 , 8.9 , 0 )
825900
826901 if action .mode != 4 {
827902 t .Errorf ("expected mode 4 (grid+PV) when PV insufficient, got %d" , action .mode )
@@ -837,7 +912,7 @@ func TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenRecentPVIsZero(t *tes
837912 LoadForecast : 0.0 ,
838913 }
839914 // recentAvgPV = 0 → gate must NOT fire even though load is also 0
840- action := decideBatteryAction (decision , 12.0 , 0.0 )
915+ action := decideBatteryAction (decision , 12.0 , 0.0 , 0 )
841916
842917 if action .mode != 4 {
843918 t .Errorf ("expected mode 4 when recentAvgPV is 0, got %d" , action .mode )
@@ -853,7 +928,7 @@ func TestDecideBatteryAction_GridCharge_GateAtExactThreshold(t *testing.T) {
853928 LoadForecast : 3.0 ,
854929 }
855930 // threshold = load(3.0) + charge(3.0) = 6.0; pass exactly 6.0 → gate fires
856- action := decideBatteryAction (decision , 12.0 , 6.0 )
931+ action := decideBatteryAction (decision , 12.0 , 6.0 , 0 )
857932
858933 if action .mode != 2 {
859934 t .Errorf ("expected mode 2 at exact threshold, got %d" , action .mode )
@@ -869,7 +944,7 @@ func TestDecideBatteryAction_GridCharge_LimitClampedToMaxCharge(t *testing.T) {
869944 LoadForecast : 1.0 ,
870945 }
871946 // recentAvgPV = 10.0 >= load(1.0) + charge(9.0) → gate fires
872- action := decideBatteryAction (decision , 8.0 , 10.0 ) // maxCharge = 8.0
947+ action := decideBatteryAction (decision , 8.0 , 10.0 , 0 ) // maxCharge = 8.0
873948
874949 if action .mode != 2 {
875950 t .Errorf ("expected mode 2, got %d" , action .mode )
0 commit comments