Skip to content

Commit f3c13e6

Browse files
committed
fix: PV recovery gate to avoid grid import
1 parent f9862ea commit f3c13e6

2 files changed

Lines changed: 135 additions & 6 deletions

File tree

‎scheduler/mpc.go‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -638,15 +638,41 @@ type batteryAction struct {
638638

639639
// decideBatteryAction translates an MPC control decision into a concrete battery
640640
// action without any side-effects, making the mapping easy to read and test.
641-
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64) batteryAction {
641+
//
642+
// recentAvgPV is the short-term average PV output (kW) measured just before
643+
// execution. When it is high enough to cover the load plus the full planned
644+
// charge rate, grid charging is suppressed and the inverter is switched to
645+
// PV-only self-use mode instead. Pass 0 to disable the gate (e.g. in tests
646+
// that are not exercising the cloud-recovery logic).
647+
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recentAvgPV float64) batteryAction {
642648
switch {
643649
case decision.BatteryChargeFromGrid > 0.01:
650+
totalPlannedCharge := decision.BatteryChargeFromPV + decision.BatteryChargeFromGrid
651+
652+
// PV recovery gate: the MPC may have planned grid charging because it
653+
// observed low solar at optimisation time (a cloud was passing). If
654+
// actual PV is now high enough to cover both the load and the full
655+
// planned charge, the cloud has cleared and the grid import is no longer
656+
// needed. Switch to Mode 2 (PV self-use) so the battery still charges
657+
// at the planned rate but entirely from solar.
658+
if recentAvgPV >= decision.LoadForecast+totalPlannedCharge {
659+
limit := math.Min(totalPlannedCharge, maxCharge)
660+
return batteryAction{
661+
mode: 2,
662+
chargeLimit: limit,
663+
setCharge: true,
664+
logMsg: fmt.Sprintf(
665+
"Grid charging suppressed (PV recovery): recent PV %.1f kW >= load %.1f kW + planned charge %.1f kW; switching to PV-only mode, limit %.1f kW",
666+
recentAvgPV, decision.LoadForecast, totalPlannedCharge, limit),
667+
}
668+
}
669+
644670
// Mode 4: Command charging (PV first, then grid).
645671
// The charge limit must be the total desired charge rate so that the
646672
// inverter can draw from both PV surplus and the grid to reach it.
647673
// Clamp to maxCharge: the inverter rejects any value above the
648674
// hardware-rated maximum with a Modbus illegal-data-address exception.
649-
limit := math.Min(decision.BatteryChargeFromPV+decision.BatteryChargeFromGrid, maxCharge)
675+
limit := math.Min(totalPlannedCharge, maxCharge)
650676
return batteryAction{
651677
mode: 4,
652678
chargeLimit: limit,
@@ -737,7 +763,14 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
737763
}
738764
s.logger.Printf("Enabled Remote EMS control")
739765

740-
action := decideBatteryAction(decision, config.BatteryMaxCharge)
766+
// Get recent average PV power for the grid-charging gate. A 5-minute
767+
// window covers ~30 readings at the default 10 s poll interval and is
768+
// long enough to distinguish a sustained cloud-free period from a brief
769+
// spike. Falls back to 0 (gate disabled) when no samples are available.
770+
const pvGateWindow = 5 * time.Minute
771+
recentAvgPV := s.dataSamples.AveragePVPowerLast(pvGateWindow)
772+
773+
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV)
741774
s.logger.Print(action.logMsg)
742775

743776
if err := client.SetRemoteEMSMode(action.mode); err != nil {

‎scheduler/mpc_test.go‎

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)