Skip to content

Commit 4d33dc3

Browse files
committed
fix: soc hysteresis band
1 parent 1c90e28 commit 4d33dc3

7 files changed

Lines changed: 696 additions & 45 deletions

File tree

‎mpc/mpc.go‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,14 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatt
522522
}
523523
socGap := mpc.Config.BatteryMaxSOC - currentSOC
524524
if socGap > 1e-9 {
525-
// Use the same balancing-aware efficiency as calculateNewSOC so that
526-
// the top-up charge correctly accounts for the extra energy required
527-
// in the CV/balancing phase when the battery is nearly full.
528-
efficiency := mpc.Config.BatteryEfficiency
525+
// Use the same DC-side, balancing-aware efficiency as calculateNewSOC
526+
// so that the top-up charge correctly accounts for the extra energy
527+
// required in the CV/balancing phase when the battery is nearly full.
528+
efficiency := 1.0
529529
if mpc.Config.BatteryBalancingSOCThreshold > 0 &&
530530
mpc.Config.BatteryBalancingEfficiencyFactor > 0 &&
531531
currentSOC >= mpc.Config.BatteryBalancingSOCThreshold {
532-
efficiency *= mpc.Config.BatteryBalancingEfficiencyFactor
532+
efficiency = mpc.Config.BatteryBalancingEfficiencyFactor
533533
}
534534
// Invert calculateNewSOC: charge needed so that
535535
// currentSOC + charge * duration * efficiency / capacity == BatteryMaxSOC
@@ -689,14 +689,15 @@ func (mpc *Controller) canCharge(soc, charge float64) bool {
689689
timeSlotDuration = 1.0
690690
}
691691

692-
// Use the same balancing-aware efficiency as calculateNewSOC: in the CV/balancing
693-
// phase near 100% SOC the same charge power produces less SOC increase, so more
694-
// charge actions remain valid (they won't overshoot BatteryMaxSOC).
695-
efficiency := mpc.Config.BatteryEfficiency
692+
// Use the same DC-side convention and balancing-aware derate as
693+
// calculateNewSOC so that both functions agree on how much the SOC actually
694+
// rises. BatteryEfficiency is deliberately NOT applied here: it is already
695+
// accounted for on the AC side of the power balance.
696+
efficiency := 1.0
696697
if mpc.Config.BatteryBalancingSOCThreshold > 0 &&
697698
mpc.Config.BatteryBalancingEfficiencyFactor > 0 &&
698699
soc >= mpc.Config.BatteryBalancingSOCThreshold {
699-
efficiency *= mpc.Config.BatteryBalancingEfficiencyFactor
700+
efficiency = mpc.Config.BatteryBalancingEfficiencyFactor
700701
}
701702

702703
// Convert power (kW) to energy (kWh) using the same formula as calculateNewSOC:
@@ -727,17 +728,27 @@ func (mpc *Controller) calculateNewSOC(currentSOC, charge, discharge float64) fl
727728
timeSlotDuration = 1.0
728729
}
729730

730-
// Apply reduced charging efficiency in the CV/balancing phase.
731-
// When currentSOC is at or above BatteryBalancingSOCThreshold the battery is in
732-
// constant-voltage mode: cells are being balanced and more input energy is needed
733-
// per unit of SOC increase. The efficiency factor captures this extra cost so
734-
// that the optimizer correctly prices charging into the very top of the SOC range.
735-
efficiency := mpc.Config.BatteryEfficiency
731+
// Charge/discharge powers are DC-side (battery terminal) quantities, which
732+
// is the convention the power balance in generateFeasibleDecisions uses:
733+
// charging draws charge/BatteryEfficiency from the AC bus and discharging
734+
// delivers discharge*BatteryEfficiency to it. The conversion loss is
735+
// therefore already accounted for on the AC side, and the DC-side energy
736+
// that actually moves in or out of the cells is simply power * duration.
737+
//
738+
// Applying BatteryEfficiency again here would double-count the charging
739+
// loss: bus->SOC would be modelled at efficiency^2 while SOC->bus stayed at
740+
// efficiency, giving an asymmetric round trip (0.778 instead of 0.846 at
741+
// efficiency=0.92) that made charging look worse than discharging.
742+
//
743+
// The CV/balancing derate is a separate physical effect and still applies:
744+
// at or above BatteryBalancingSOCThreshold the cells are being balanced and
745+
// far more input energy is needed per unit of SOC gain.
746+
efficiency := 1.0
736747
if charge > 0 &&
737748
mpc.Config.BatteryBalancingSOCThreshold > 0 &&
738749
mpc.Config.BatteryBalancingEfficiencyFactor > 0 &&
739750
currentSOC >= mpc.Config.BatteryBalancingSOCThreshold {
740-
efficiency *= mpc.Config.BatteryBalancingEfficiencyFactor
751+
efficiency = mpc.Config.BatteryBalancingEfficiencyFactor
741752
}
742753

743754
// Convert power (kW) to energy (kWh) by multiplying by time slot duration

‎mpc/mpc_balancing_test.go‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,21 +133,25 @@ func TestCVPhase_ReducedSOCIncrease(t *testing.T) {
133133

134134
// Use a small charge power so neither SOC trajectory overshoots BatteryMaxSOC
135135
// and gets clamped, which would mask the efficiency difference we are testing.
136-
// Below threshold: 0.989 + 0.01*1.0*0.9/10 = 0.9899 (< 1.0 ✓)
137-
// At threshold: 0.999 + 0.01*1.0*0.27/10 = 0.99927 (< 1.0 ✓)
136+
//
137+
// Charge power is DC-side, so BatteryEfficiency does NOT appear here — it is
138+
// accounted for on the AC side of the power balance. Only the CV-phase
139+
// derate applies:
140+
// Below threshold: 0.989 + 0.01*1.0*1.0/10 = 0.99 (< 1.0 ✓)
141+
// At threshold: 0.999 + 0.01*1.0*0.3/10 = 0.9993 (< 1.0 ✓)
138142
chargeKW := 0.01 // kW for one hour
139143

140-
// ── Below threshold: normal efficiency ──────────────────────────────────
144+
// ── Below threshold: no derate ──────────────────────────────────────────
141145
socBelow := config.BatteryBalancingSOCThreshold - 0.01 // e.g. 98.9 %
142146
newSOCBelow := ctrl.calculateNewSOC(socBelow, chargeKW, 0)
143147

144-
wantBelow := socBelow + chargeKW*config.TimeSlotDuration*config.BatteryEfficiency/config.BatteryCapacity
148+
wantBelow := socBelow + chargeKW*config.TimeSlotDuration/config.BatteryCapacity
145149
if math.Abs(newSOCBelow-wantBelow) > 1e-9 {
146150
t.Errorf("Below threshold: calculateNewSOC=%.8f, want %.8f", newSOCBelow, wantBelow)
147151
}
148152

149153
// ── At threshold: reduced (CV-phase) efficiency ──────────────────────────
150-
cvEff := config.BatteryEfficiency * config.BatteryBalancingEfficiencyFactor
154+
cvEff := config.BatteryBalancingEfficiencyFactor
151155
socAt := config.BatteryBalancingSOCThreshold
152156
newSOCAt := ctrl.calculateNewSOC(socAt, chargeKW, 0)
153157

‎scheduler/config.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,24 @@ type Config struct {
9191
BatteryBalancingEfficiencyFactor float64 `json:"battery_balancing_efficiency_factor"` // multiplier on BatteryEfficiency during CV phase (e.g. 0.3); 0 disables
9292
BatteryBalancingBonus float64 `json:"battery_balancing_bonus"` // one-time profit bonus awarded when battery first reaches BatteryMaxSOC within the horizon; 0 disables
9393

94+
// Top-of-charge hold parameters.
95+
//
96+
// When the battery sits at/above BatteryBalancingSOCThreshold and weekly
97+
// cell-balancing is not due, opportunistic charging is blocked so the pack
98+
// is not pushed into its low-efficiency CV phase. Releasing that block the
99+
// moment SOC dips back below the threshold produces a limit cycle: the
100+
// inverter's auxiliary draw slowly depletes the pack, charging is
101+
// re-enabled, PV tops it straight back up, and the block re-engages — the
102+
// battery micro-cycles around the threshold for hours.
103+
//
104+
// BatteryBalancingSOCReleaseBand adds hysteresis: once blocked, charging is
105+
// only re-enabled after SOC falls below (threshold - releaseBand).
106+
// BatteryTopHoldPower allows a small trickle while blocked so the inverter's
107+
// own consumption is served from PV/grid rather than from the pack, which
108+
// removes the drift that drives the cycle in the first place.
109+
BatteryBalancingSOCReleaseBand float64 `json:"battery_balancing_soc_release_band"` // SOC hysteresis band (0-1) below BatteryBalancingSOCThreshold; 0 disables hysteresis
110+
BatteryTopHoldPower float64 `json:"battery_top_hold_power"` // kW trickle charge permitted while the top-of-charge block is engaged; 0 forces a hard idle
111+
94112
// Price adjustments
95113
ImportPriceOperatorFee float64 `json:"import_price_operator_fee"` // EUR/MWh - Operator fee for import
96114
ImportPriceDeliveryFee float64 `json:"import_price_delivery_fee"` // EUR/MWh - Delivery fee for import
@@ -151,6 +169,8 @@ func DefaultConfig() *Config {
151169
BatteryBalancingSOCThreshold: 0.998, // CV phase starts at 99.8%
152170
BatteryBalancingEfficiencyFactor: 0.1, // ~10× more input energy needed in CV phase
153171
BatteryBalancingBonus: 1.1, // $1.1 one-time optimisation bonus for reaching BatteryMaxSOC
172+
BatteryBalancingSOCReleaseBand: 0.01, // resume charging only once SOC drops 1% below the threshold (98.8% by default)
173+
BatteryTopHoldPower: 0.2, // 0.2 kW (200 W) trickle to cover inverter auxiliary draw while holding at the top
154174
}
155175
}
156176

@@ -413,6 +433,23 @@ func (c *Config) Validate() error {
413433
return fmt.Errorf("battery_balancing_bonus must be non-negative, got: %f", c.BatteryBalancingBonus)
414434
}
415435

436+
if c.BatteryBalancingSOCReleaseBand < 0 || c.BatteryBalancingSOCReleaseBand > 1 {
437+
return fmt.Errorf("battery_balancing_soc_release_band must be between 0 and 1, got: %f", c.BatteryBalancingSOCReleaseBand)
438+
}
439+
440+
if c.BatteryBalancingSOCThreshold != 0 && c.BatteryBalancingSOCReleaseBand >= c.BatteryBalancingSOCThreshold {
441+
return fmt.Errorf("battery_balancing_soc_release_band (%f) must be smaller than battery_balancing_soc_threshold (%f), otherwise charging would never resume",
442+
c.BatteryBalancingSOCReleaseBand, c.BatteryBalancingSOCThreshold)
443+
}
444+
445+
if c.BatteryTopHoldPower < 0 {
446+
return fmt.Errorf("battery_top_hold_power must be non-negative, got: %f", c.BatteryTopHoldPower)
447+
}
448+
449+
if c.BatteryTopHoldPower > c.BatteryMaxCharge {
450+
return fmt.Errorf("battery_top_hold_power (%f) must not exceed battery_max_charge (%f)", c.BatteryTopHoldPower, c.BatteryMaxCharge)
451+
}
452+
416453
// Validate that MaxGridImport can handle battery charging with preheating
417454
// When charging at maximum rate with battery preheating active, total grid import will be:
418455
// BatteryMaxCharge/Efficiency + BatteryPreHeatPower (plus any load)

‎scheduler/mpc.go‎

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,49 @@ type batteryAction struct {
668668
setCharge bool
669669
setDischarge bool
670670
logMsg string
671+
672+
// topBlocked reports the state of the top-of-charge latch after this
673+
// decision. The caller must persist it and feed it back in via
674+
// topSOCGuard.blocked on the next call so the hysteresis works.
675+
topBlocked bool
676+
}
677+
678+
// topSOCGuard configures the top-of-charge hold that keeps the battery out of
679+
// its low-efficiency CV/balancing phase outside the weekly balancing window.
680+
//
681+
// It is a Schmitt trigger on SOC:
682+
//
683+
// engage when SOC >= threshold
684+
// release when SOC < threshold - releaseBand
685+
//
686+
// Without the releaseBand the guard chatters: blocking charge lets the
687+
// inverter's auxiliary load drain the pack a few tenths of a percent, SOC drops
688+
// back under the threshold, charging is re-enabled, PV refills it in a single
689+
// slot, and the guard engages again — a ~30-45 minute limit cycle that keeps
690+
// the pack pinned at the very top while micro-cycling it.
691+
//
692+
// holdPower is the trickle (kW) still permitted while engaged. A hard 0 forces
693+
// the inverter to serve its own auxiliary consumption from the battery, which
694+
// is what produces the downward drift; allowing a small charge lets that draw
695+
// come from PV or the grid instead so SOC simply holds.
696+
type topSOCGuard struct {
697+
threshold float64 // SOC (0-1) at/above which charging is blocked; 0 disables the guard
698+
releaseBand float64 // SOC hysteresis band below threshold; 0 means release as soon as SOC < threshold
699+
holdPower float64 // kW trickle permitted while blocked
700+
blocked bool // latch state carried over from the previous execution
701+
}
702+
703+
// engaged applies the Schmitt trigger to the given SOC and reports whether
704+
// charging should be blocked.
705+
func (g topSOCGuard) engaged(soc float64) bool {
706+
if g.threshold <= 0 {
707+
return false
708+
}
709+
if g.blocked {
710+
// Stay blocked until SOC falls clear of the hysteresis band.
711+
return soc >= g.threshold-g.releaseBand
712+
}
713+
return soc >= g.threshold
671714
}
672715

673716
// decideBatteryAction translates an MPC control decision into a concrete battery
@@ -679,11 +722,11 @@ type batteryAction struct {
679722
// PV-only self-use mode instead. Pass 0 to disable the gate (e.g. in tests
680723
// that are not exercising the cloud-recovery logic).
681724
//
682-
// balancingSOCThreshold is the SOC (0-1) at/above which the battery enters its
683-
// low-efficiency CV/balancing phase (config.BatteryBalancingSOCThreshold).
684-
// Pass 0 to disable the related guard in the default case below (e.g. in
685-
// tests, or when the feature is disabled in config).
686-
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recentAvgPV float64, balancingSOCThreshold float64) batteryAction {
725+
// guard configures the top-of-charge hold applied in the default case below.
726+
// Pass the zero value to disable it (e.g. in tests, or when the feature is
727+
// disabled in config).
728+
func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recentAvgPV float64, guard topSOCGuard) batteryAction {
729+
687730
switch {
688731
case decision.BatteryChargeFromGrid > 0.01:
689732
totalPlannedCharge := decision.BatteryChargeFromPV + decision.BatteryChargeFromGrid
@@ -771,13 +814,24 @@ func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recen
771814
// headroom up to BatteryMaxSOC. Doing so would push the battery into
772815
// its low-efficiency CV/balancing phase every time PV surplus is
773816
// available, rather than only once a week as intended.
817+
//
818+
// The guard is a Schmitt trigger (see topSOCGuard): it engages at the
819+
// threshold and only releases once SOC has fallen clear of the
820+
// hysteresis band, and it leaves a small trickle enabled so the
821+
// inverter's auxiliary draw does not slowly empty the pack. Together
822+
// these stop the battery micro-cycling around the threshold.
774823
chargeLimit := maxCharge
775824
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",
776825
maxCharge, decision.GridImport, decision.GridExport)
777-
if balancingSOCThreshold > 0 && !decision.BalancingNeeded && decision.BatterySOC >= balancingSOCThreshold {
778-
chargeLimit = 0
779-
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",
780-
decision.BatterySOC*100, balancingSOCThreshold*100, decision.GridImport, decision.GridExport)
826+
827+
topBlocked := false
828+
if !decision.BalancingNeeded {
829+
topBlocked = guard.engaged(decision.BatterySOC)
830+
}
831+
if topBlocked {
832+
chargeLimit = math.Min(guard.holdPower, maxCharge)
833+
logMsg = fmt.Sprintf("Setting battery to SELF-CONSUMPTION mode (SOC %.1f%% within top-of-charge hold: engage >= %.1f%%, release < %.1f%%, balancing not currently needed — charge capped at %.2f kW to hold SOC without entering the CV/balancing phase): GridImport: %.1f kW, GridExport: %.1f kW",
834+
decision.BatterySOC*100, guard.threshold*100, (guard.threshold-guard.releaseBand)*100, chargeLimit, decision.GridImport, decision.GridExport)
781835
}
782836
return batteryAction{
783837
mode: 2,
@@ -786,6 +840,7 @@ func decideBatteryAction(decision *mpc.ControlDecision, maxCharge float64, recen
786840
setCharge: true,
787841
setDischarge: true,
788842
logMsg: logMsg,
843+
topBlocked: topBlocked,
789844
}
790845
}
791846
}
@@ -827,9 +882,24 @@ func (s *MinerScheduler) executeMPCDecision(ctx context.Context, decision *mpc.C
827882
const pvGateWindow = 5 * time.Minute
828883
recentAvgPV := s.dataSamples.AveragePVPowerLast(pvGateWindow)
829884

830-
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV, config.BatteryBalancingSOCThreshold)
885+
s.mu.RLock()
886+
topBlocked := s.topChargeBlocked
887+
s.mu.RUnlock()
888+
889+
guard := topSOCGuard{
890+
threshold: config.BatteryBalancingSOCThreshold,
891+
releaseBand: config.BatteryBalancingSOCReleaseBand,
892+
holdPower: config.BatteryTopHoldPower,
893+
blocked: topBlocked,
894+
}
895+
896+
action := decideBatteryAction(decision, config.BatteryMaxCharge, recentAvgPV, guard)
831897
s.logger.Print(action.logMsg)
832898

899+
s.mu.Lock()
900+
s.topChargeBlocked = action.topBlocked
901+
s.mu.Unlock()
902+
833903
// Enforce hardware-level grid export limit based on the current export price.
834904
// When the export price is negative (or zero), set the inverter's grid-point
835905
// export limit to 0 so no power can reach the grid regardless of ESS mode or

0 commit comments

Comments
 (0)