Skip to content

Commit da97819

Browse files
committed
fix: extra PV absorbtion
1 parent 3477582 commit da97819

3 files changed

Lines changed: 118 additions & 14 deletions

File tree

‎mpc/mpc.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ type ControlDecision struct {
8383
WeatherSymbol string // weather condition symbol
8484
BatteryAvgCellTemp float64 // °C average cell temperature
8585
AirTemperature float64 // °C air temperature
86+
// BalancingNeeded reports whether this optimisation run determined that
87+
// weekly cell-balancing (charging to BatteryMaxSOC) should be incentivised.
88+
// It is the same value for every decision returned by a given Optimize
89+
// call, since needsWeeklyBalancing is evaluated once per run. Callers can
90+
// use it to distinguish "battery is full because balancing was due" from
91+
// "battery is full incidentally", e.g. to avoid opportunistically pushing
92+
// SOC toward BatteryMaxSOC outside of the weekly balancing window.
93+
BalancingNeeded bool
8694
}
8795

8896
// Controller implements Model Predictive Control
@@ -284,6 +292,8 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
284292
// calculateProfit(dec, slot) before any cell-balancing bonus, which is
285293
// tracked separately and not part of this per-slot field).
286294
finalDecisions[i].Profit = mpc.calculateProfit(finalDecisions[i], slot)
295+
296+
finalDecisions[i].BalancingNeeded = needsBalancing
287297
}
288298

289299
return finalDecisions

‎scheduler/mpc.go‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,12 @@ type batteryAction struct {
671671
// charge rate, grid charging is suppressed and the inverter is switched to
672672
// PV-only self-use mode instead. Pass 0 to disable the gate (e.g. in tests
673673
// that are not exercising the cloud-recovery logic).
674-
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recentAvgPV float64) batteryAction {
674+
//
675+
// balancingSOCThreshold is the SOC (0-1) at/above which the battery enters its
676+
// low-efficiency CV/balancing phase (config.BatteryBalancingSOCThreshold).
677+
// Pass 0 to disable the related guard in the default case below (e.g. in
678+
// tests, or when the feature is disabled in config).
679+
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recentAvgPV float64, balancingSOCThreshold float64) batteryAction {
675680
switch {
676681
case decision.BatteryChargeFromGrid > 0.01:
677682
totalPlannedCharge := decision.BatteryChargeFromPV + decision.BatteryChargeFromGrid
@@ -752,14 +757,28 @@ func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recen
752757
// forecasts can underestimate solar (e.g. clouds clearing sooner
753758
// than expected) — while keeping discharge capped at 0 so the
754759
// battery doesn't drain against the plan.
760+
//
761+
// Exception: if the battery is already at/above the balancing SOC
762+
// threshold and this optimisation run did not call for weekly
763+
// cell-balancing, don't opportunistically chase the remaining
764+
// headroom up to BatteryMaxSOC. Doing so would push the battery into
765+
// its low-efficiency CV/balancing phase every time PV surplus is
766+
// available, rather than only once a week as intended.
767+
chargeLimit := maxCharge
768+
logMsg := fmt.Sprintf("Setting battery to SELF-CONSUMPTION mode (no active charge/discharge planned, absorbing any actual PV excess up to %.1f kW): GridImport: %.1f kW, GridExport: %.1f kW",
769+
maxCharge, decision.GridImport, decision.GridExport)
770+
if balancingSOCThreshold > 0 && !decision.BalancingNeeded && decision.BatterySOC >= balancingSOCThreshold {
771+
chargeLimit = 0
772+
logMsg = fmt.Sprintf("Setting battery to SELF-CONSUMPTION mode (SOC %.1f%% already at/above balancing threshold %.1f%% and balancing not currently needed — capping charge at 0 to avoid unnecessary CV/balancing): GridImport: %.1f kW, GridExport: %.1f kW",
773+
decision.BatterySOC*100, balancingSOCThreshold*100, decision.GridImport, decision.GridExport)
774+
}
755775
return batteryAction{
756776
mode: 2,
757-
chargeLimit: maxCharge,
777+
chargeLimit: chargeLimit,
758778
dischargeLimit: 0,
759779
setCharge: true,
760780
setDischarge: true,
761-
logMsg: fmt.Sprintf("Setting battery to SELF-CONSUMPTION mode (no active charge/discharge planned, absorbing any actual PV excess up to %.1f kW): GridImport: %.1f kW, GridExport: %.1f kW",
762-
maxCharge, decision.GridImport, decision.GridExport),
781+
logMsg: logMsg,
763782
}
764783
}
765784
}
@@ -801,7 +820,7 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
801820
const pvGateWindow = 5 * time.Minute
802821
recentAvgPV := s.dataSamples.AveragePVPowerLast(pvGateWindow)
803822

804-
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV)
823+
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV, config.BatteryBalancingSOCThreshold)
805824
s.logger.Print(action.logMsg)
806825

807826
// Enforce hardware-level grid export limit based on the current export price.

‎scheduler/mpc_test.go‎

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

Comments
 (0)