@@ -697,7 +697,7 @@ func TestDecideBatteryAction_DischargeNegativeExportUsesMode2(t *testing.T) {
697697 BatteryChargeFromGrid : 0 ,
698698 BatteryChargeFromPV : 0 ,
699699 }
700- action := decideBatteryAction (decision , 5.0 )
700+ action := decideBatteryAction (decision , 5.0 , 0.0 )
701701 if action .mode != 2 {
702702 t .Errorf ("expected mode 2, got %d" , action .mode )
703703 }
@@ -713,7 +713,7 @@ func TestDecideBatteryAction_DischargeZeroExportUsesMode2(t *testing.T) {
713713 BatteryChargeFromGrid : 0 ,
714714 BatteryChargeFromPV : 0 ,
715715 }
716- action := decideBatteryAction (decision , 5.0 )
716+ action := decideBatteryAction (decision , 5.0 , 0.0 )
717717 if action .mode != 2 {
718718 t .Errorf ("expected mode 2, got %d" , action .mode )
719719 }
@@ -732,7 +732,7 @@ func TestDecideBatteryAction_DischargeWithPlannedGridExportUsesMode5(t *testing.
732732 BatteryChargeFromGrid : 0 ,
733733 BatteryChargeFromPV : 0 ,
734734 }
735- action := decideBatteryAction (decision , 5.0 )
735+ action := decideBatteryAction (decision , 5.0 , 0.0 )
736736 if action .mode != 5 {
737737 t .Errorf ("expected mode 5, got %d" , action .mode )
738738 }
@@ -743,3 +743,99 @@ func TestDecideBatteryAction_DischargeWithPlannedGridExportUsesMode5(t *testing.
743743 t .Errorf ("expected dischargeLimit 3.0, got %.2f" , action .dischargeLimit )
744744 }
745745}
746+
747+ // ---------------------------------------------------------------------------
748+ // PV recovery gate tests (grid charging suppression when cloud clears)
749+ // ---------------------------------------------------------------------------
750+
751+ // TestDecideBatteryAction_GridCharge_GateSuppressesWhenPVCoversAll verifies that
752+ // when recent PV is high enough to cover load + full planned charge, the gate
753+ // fires and switches from Mode 4 (grid+PV) to Mode 2 (PV-only).
754+ func TestDecideBatteryAction_GridCharge_GateSuppressesWhenPVCoversAll (t * testing.T ) {
755+ decision := & mpc.ControlDecision {
756+ BatteryChargeFromPV : 2.0 ,
757+ BatteryChargeFromGrid : 3.0 ,
758+ LoadForecast : 4.0 ,
759+ }
760+ // recentAvgPV = 9.0 >= load(4.0) + charge(5.0) → gate fires
761+ action := decideBatteryAction (decision , 12.0 , 9.0 )
762+
763+ if action .mode != 2 {
764+ t .Errorf ("expected mode 2 (PV-only) after gate, got %d" , action .mode )
765+ }
766+ if ! action .setCharge {
767+ t .Errorf ("expected setCharge true" )
768+ }
769+ // Limit must equal the full planned charge (PV + grid portions)
770+ if action .chargeLimit != 5.0 {
771+ t .Errorf ("expected chargeLimit 5.0 (full planned charge), got %.2f" , action .chargeLimit )
772+ }
773+ }
774+
775+ // TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenPVInsufficient verifies
776+ // that the gate does NOT fire when recent PV is below the load + charge threshold,
777+ // preserving Mode 4 (grid + PV) charging.
778+ func TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenPVInsufficient (t * testing.T ) {
779+ decision := & mpc.ControlDecision {
780+ BatteryChargeFromPV : 2.0 ,
781+ BatteryChargeFromGrid : 3.0 ,
782+ LoadForecast : 4.0 ,
783+ }
784+ // recentAvgPV = 8.9 < load(4.0) + charge(5.0) = 9.0 → gate must NOT fire
785+ action := decideBatteryAction (decision , 12.0 , 8.9 )
786+
787+ if action .mode != 4 {
788+ t .Errorf ("expected mode 4 (grid+PV) when PV insufficient, got %d" , action .mode )
789+ }
790+ }
791+
792+ // TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenRecentPVIsZero verifies
793+ // that passing recentAvgPV=0 (no samples / startup) never triggers the gate.
794+ func TestDecideBatteryAction_GridCharge_GateDoesNotFireWhenRecentPVIsZero (t * testing.T ) {
795+ decision := & mpc.ControlDecision {
796+ BatteryChargeFromPV : 1.0 ,
797+ BatteryChargeFromGrid : 2.0 ,
798+ LoadForecast : 0.0 ,
799+ }
800+ // recentAvgPV = 0 → gate must NOT fire even though load is also 0
801+ action := decideBatteryAction (decision , 12.0 , 0.0 )
802+
803+ if action .mode != 4 {
804+ t .Errorf ("expected mode 4 when recentAvgPV is 0, got %d" , action .mode )
805+ }
806+ }
807+
808+ // TestDecideBatteryAction_GridCharge_GateAtExactThreshold verifies the gate fires
809+ // at exactly the threshold (>=, not >).
810+ func TestDecideBatteryAction_GridCharge_GateAtExactThreshold (t * testing.T ) {
811+ decision := & mpc.ControlDecision {
812+ BatteryChargeFromPV : 1.0 ,
813+ BatteryChargeFromGrid : 2.0 ,
814+ LoadForecast : 3.0 ,
815+ }
816+ // threshold = load(3.0) + charge(3.0) = 6.0; pass exactly 6.0 → gate fires
817+ action := decideBatteryAction (decision , 12.0 , 6.0 )
818+
819+ if action .mode != 2 {
820+ t .Errorf ("expected mode 2 at exact threshold, got %d" , action .mode )
821+ }
822+ }
823+
824+ // TestDecideBatteryAction_GridCharge_LimitClampedToMaxCharge verifies that when
825+ // the gate fires and the planned charge exceeds maxCharge, the limit is clamped.
826+ func TestDecideBatteryAction_GridCharge_LimitClampedToMaxCharge (t * testing.T ) {
827+ decision := & mpc.ControlDecision {
828+ BatteryChargeFromPV : 4.0 ,
829+ BatteryChargeFromGrid : 5.0 , // total = 9.0, above maxCharge
830+ LoadForecast : 1.0 ,
831+ }
832+ // recentAvgPV = 10.0 >= load(1.0) + charge(9.0) → gate fires
833+ action := decideBatteryAction (decision , 8.0 , 10.0 ) // maxCharge = 8.0
834+
835+ if action .mode != 2 {
836+ t .Errorf ("expected mode 2, got %d" , action .mode )
837+ }
838+ if action .chargeLimit != 8.0 {
839+ t .Errorf ("expected chargeLimit clamped to maxCharge 8.0, got %.2f" , action .chargeLimit )
840+ }
841+ }
0 commit comments