@@ -123,14 +123,29 @@ func NewController(config SystemConfig, horizon int, initialSOC float64) *Contro
123123 return c
124124}
125125
126- // Optimize finds the optimal control strategy using dynamic programming.
127- // It runs two optimizations: one with solar forecast and one without (grid-only),
128- // then splits BatteryCharge into BatteryChargeFromPV and BatteryChargeFromGrid.
126+ // Optimize finds the optimal control strategy using dynamic programming, then splits
127+ // the resulting charge into BatteryChargeFromPV and BatteryChargeFromGrid.
129128//
130- // Grid charging is suppressed entirely when the forecasted solar surplus over the
131- // horizon is sufficient to charge the battery from its current SOC to full. This
132- // prevents unnecessary grid imports during temporary cloud cover when overall daily
133- // solar production is more than enough to meet the full charge requirement.
129+ // The split is derived from the single solar-aware DP pass: whatever the PV surplus
130+ // can cover is attributed to PV, and the remainder is grid charge. Deriving it this
131+ // way is what keeps overnight grid charging honest — the optimizer sizes the night's
132+ // imports knowing how much free PV is arriving in the morning, so it buys only what
133+ // still leaves headroom for that PV rather than filling the battery at 03:00 and
134+ // forcing the next day's generation to be exported at midday's low prices.
135+ //
136+ // Grid charging is additionally suppressed *within the daylight window* when the
137+ // forecasted solar surplus is sufficient to charge the battery from its current SOC
138+ // to full. This prevents unnecessary grid imports during temporary cloud cover when
139+ // overall daily solar production is more than enough to meet the full charge
140+ // requirement.
141+ //
142+ // That suppression deliberately does NOT extend to slots outside the daylight window.
143+ // Before sunrise and after sunset there is no PV to wait for, so "today's sun will
144+ // cover it" is not an argument against buying cheap energy: overnight
145+ // charge-low/discharge-high arbitrage is profitable independently of how sunny the
146+ // following day turns out to be. Applying the gate across the whole horizon left the
147+ // battery idle through the cheapest hours of the night purely because the next
148+ // afternoon looked bright.
134149func (mpc * Controller ) Optimize (forecast []TimeSlot ) []ControlDecision {
135150 if len (forecast ) == 0 {
136151 return nil
@@ -140,14 +155,9 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
140155 // incentivised during this optimisation run – at most once per calendar week.
141156 needsBalancing := mpc .needsWeeklyBalancing (forecast )
142157
143- // Run optimization with full solar forecast.
158+ // Run optimization with the full solar forecast.
144159 decisionsWithSolar := mpc .optimizeWithForecast (forecast , true , needsBalancing )
145160
146- // Run optimization without solar (grid-only scenario).
147- // Used to identify grid charging that is profitable regardless of solar, so
148- // that the battery still charges from the grid on genuinely overcast days.
149- decisionsWithoutSolar := mpc .optimizeWithForecast (forecast , false , needsBalancing )
150-
151161 n := min (len (decisionsWithSolar ), len (forecast ))
152162
153163 // ── Solar-sufficiency check ──────────────────────────────────────────────
@@ -165,24 +175,52 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
165175 // Energy (kWh at the AC input) needed to charge from CurrentSOC to
166176 // BatteryMaxSOC, accounting for charging efficiency.
167177 energyNeededToFull := math .Max (0 ,
168- (mpc .Config .BatteryMaxSOC - mpc .CurrentSOC )* mpc .Config .BatteryCapacity / mpc .Config . BatteryEfficiency )
178+ (mpc .Config .BatteryMaxSOC - mpc .CurrentSOC )* mpc .Config .BatteryCapacity / mpc .batteryEfficiency () )
169179
170180 // Only count solar surplus up to the nearest sunset. Summing across a
171181 // multi-day horizon would let solar production from a future day mask an
172182 // inability to charge fully today, causing grid charging to be suppressed
173- // even though today's solar alone can't cover it. Sunset is identified as
174- // the first slot where forecasted solar returns to zero after having been
175- // positive (i.e. the sun has set); if that never happens within the
176- // horizon (e.g. no daylight forecast at all), the full horizon is used.
183+ // even though today's solar alone can't cover it.
184+ //
185+ // Sunset cannot simply be "the first zero-solar slot after the sun came up":
186+ // a passing cloud also drives the forecast to zero, and treating it as
187+ // sunset would cut the day short — collapsing the surplus total and
188+ // defeating the very transient-cloud case this heuristic exists to handle.
189+ // Instead a sunset is a zero-solar run long enough to be actual night
190+ // (nightGapSlots ≈ 3 hours); shorter gaps are cloud cover and are absorbed
191+ // into the day. If no such run occurs within the horizon (e.g. the horizon
192+ // ends mid-afternoon, or there is no daylight forecast at all) the full
193+ // horizon is used.
194+ //
195+ // sunriseIdx is the first slot with any forecasted solar. Together the two
196+ // indices delimit the daylight window [sunriseIdx, sunsetIdx) in which the
197+ // solar-sufficiency argument actually applies.
198+ nightGapSlots := int (math .Ceil (3.0 / timeSlotDuration ))
199+ if nightGapSlots < 1 {
200+ nightGapSlots = 1
201+ }
202+
203+ sunriseIdx := n
177204 sunsetIdx := n
178205 sawSun := false
206+ zeroRunStart := - 1
179207 for i , slot := range forecast [:n ] {
180208 if slot .SolarForecast > 0 {
209+ if ! sawSun {
210+ sunriseIdx = i
211+ }
181212 sawSun = true
213+ zeroRunStart = - 1
214+ continue
215+ }
216+ if ! sawSun {
182217 continue
183218 }
184- if sawSun {
185- sunsetIdx = i
219+ if zeroRunStart < 0 {
220+ zeroRunStart = i
221+ }
222+ if i - zeroRunStart + 1 >= nightGapSlots {
223+ sunsetIdx = zeroRunStart
186224 break
187225 }
188226 }
@@ -198,19 +236,17 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
198236 // avoid pulling from the grid during transient cloud cover.
199237 solarSufficient := totalSolarSurplus >= energyNeededToFull
200238
201- // ── Combine results ──────────────────────────────────────────────────────
202- // The solar-scenario decision accounts for the full charge rate (PV surplus
203- // first, topped up by grid when profitable). We derive the PV portion as
204- // whatever charge the solar surplus can cover, and the grid portion from
205- // the without-solar scenario (unless solar is sufficient for the day).
239+ // ── Split the optimized charge into PV and grid portions ────────────────
240+ // The DP decision accounts for the full charge rate; we attribute whatever
241+ // the PV surplus can cover to PV and the remainder to the grid.
206242 //
207243 // Splitting can *reduce* the charge that will actually happen versus what
208- // the with-solar DP scenario assumed (e.g. grid top-up gets suppressed
209- // entirely when solarSufficient is true ). Everything that scenario derived
210- // from its charge amount — GridImport/GridExport, battery preheating, the
211- // SOC trajectory, and Profit — is therefore recomputed below from the
212- // actual (possibly lower) charge so the returned decisions stay internally
213- // consistent with what will really be executed.
244+ // the DP assumed (grid top-up gets suppressed when solarSufficient is true
245+ // inside the daylight window ). Everything the DP derived from its charge
246+ // amount — GridImport/GridExport, battery preheating, the SOC trajectory,
247+ // and Profit — is therefore recomputed below from the actual (possibly
248+ // lower) charge so the returned decisions stay internally consistent with
249+ // what will really be executed.
214250 finalDecisions := make ([]ControlDecision , n )
215251 runningSOC := mpc .CurrentSOC
216252 for i , slot := range forecast [:n ] {
@@ -225,20 +261,48 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
225261 // (capped at the total charge being applied).
226262 pvPortion := math .Min (pvSurplus , totalCharge )
227263
228- // Grid portion: use the without-solar scenario's recommendation, but
229- // suppress it entirely when daily solar production is sufficient to
230- // charge the battery in full. When allowed, cap so that
231- // pvPortion + gridPortion never exceeds the hardware-rated maximum
232- // charge power (exceeding it would cause the inverter to reject the
233- // register write with an illegal-data error).
264+ // Grid portion: the part of the DP's charge that PV cannot cover,
265+ // suppressed when daily solar production is sufficient to charge the
266+ // battery in full *and* this slot lies inside the daylight window.
267+ // Outside that window (before sunrise / after sunset) there is no PV
268+ // on the way, so cheap-hour grid charging stays available and normal
269+ // overnight arbitrage is preserved.
270+ //
271+ // Because totalCharge comes from the solar-aware DP, the grid portion
272+ // is already sized in the knowledge of the coming day's generation: it
273+ // buys only what still leaves room for that PV.
274+ inDaylightWindow := i >= sunriseIdx && i < sunsetIdx
234275 gridPortion := 0.0
235- if ! solarSufficient {
236- gridPortion = math .Min (decisionsWithoutSolar [i ].batteryCharge , mpc .Config .BatteryMaxCharge - pvPortion )
237- gridPortion = math .Max (0 , gridPortion )
276+ if ! solarSufficient || ! inDaylightWindow {
277+ gridPortion = math .Max (0 , totalCharge - pvPortion )
238278 }
239279
240280 actualCharge := pvPortion + gridPortion
241281
282+ // Clamp charge and discharge to what the battery can actually accept or
283+ // deliver from runningSOC. The DP's SOC trajectory differs from the
284+ // reconciled one computed here whenever the grid top-up was suppressed
285+ // above. Without this clamp calculateNewSOC silently saturates at the
286+ // SOC limits while the grid import/export below is still sized for the
287+ // unclamped power — producing plans that import into a full battery or
288+ // export energy the battery does not hold (observed in production as a
289+ // 20 kW discharge scheduled from a battery at 0.2% SOC).
290+ discharge := finalDecisions [i ].BatteryDischarge
291+
292+ if maxCharge := mpc .maxChargePower (runningSOC , timeSlotDuration ); actualCharge > maxCharge {
293+ // Give up the grid top-up first: PV surplus is free and would
294+ // otherwise have to be curtailed or exported, whereas grid charge
295+ // is purely optional and costs money.
296+ pvPortion = math .Min (pvPortion , maxCharge )
297+ gridPortion = math .Max (0 , maxCharge - pvPortion )
298+ actualCharge = pvPortion + gridPortion
299+ }
300+
301+ if maxDischarge := mpc .maxDischargePower (runningSOC , timeSlotDuration ); discharge > maxDischarge {
302+ discharge = math .Max (0 , maxDischarge )
303+ finalDecisions [i ].BatteryDischarge = discharge
304+ }
305+
242306 finalDecisions [i ].BatteryChargeFromPV = pvPortion
243307 finalDecisions [i ].BatteryChargeFromGrid = gridPortion
244308
@@ -261,10 +325,10 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
261325 // included a grid top-up; when that top-up is suppressed above, the
262326 // import/export figures must be recalculated — otherwise the
263327 // decision would report a grid import that funds a charge which no
264- // longer happens.
265- discharge := finalDecisions [ i ]. BatteryDischarge
266- netSupply := slot .SolarForecast + discharge * mpc .Config . BatteryEfficiency
267- netLoad := slot . LoadForecast + actualCharge / mpc . Config . BatteryEfficiency + extraLoad
328+ // longer happens. `discharge` is the clamped value from above.
329+ netSupply := slot . SolarForecast + discharge * mpc . batteryEfficiency ()
330+ netLoad := slot .LoadForecast + actualCharge / mpc .batteryEfficiency () + extraLoad
331+
268332 balance := netSupply - netLoad
269333
270334 if balance > 0 {
@@ -577,8 +641,8 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatt
577641 extraLoad = preHeatPower
578642 }
579643
580- netLoad := slot .LoadForecast + action .charge / mpc .Config . BatteryEfficiency + extraLoad
581- netSupply := netSolar + action .discharge * mpc .Config . BatteryEfficiency
644+ netLoad := slot .LoadForecast + action .charge / mpc .batteryEfficiency () + extraLoad
645+ netSupply := netSolar + action .discharge * mpc .batteryEfficiency ()
582646
583647 balance := netSupply - netLoad
584648
@@ -605,12 +669,12 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatt
605669 // in netLoad), so `balance` is the PV power that still has nowhere
606670 // to go. Try to increase charging to absorb it — but only up to the
607671 // hardware maximum and SOC limits.
608- extraCharge := math .Min (balance * mpc .Config . BatteryEfficiency , mpc .Config .BatteryMaxCharge - action .charge )
672+ extraCharge := math .Min (balance * mpc .batteryEfficiency () , mpc .Config .BatteryMaxCharge - action .charge )
609673 if extraCharge > 0 && mpc .canCharge (currentSOC , action .charge + extraCharge ) {
610674 // Absorb as much surplus as possible into the battery.
611675 dec .batteryCharge += extraCharge
612676 // Recalculate balance after the extra charging.
613- balance -= extraCharge / mpc .Config . BatteryEfficiency
677+ balance -= extraCharge / mpc .batteryEfficiency ()
614678 }
615679
616680 if balance > 0.001 {
@@ -704,6 +768,13 @@ func (mpc *Controller) canCharge(soc, charge float64) bool {
704768 // multiply by time slot duration AND efficiency so that both functions agree on
705769 // how much the SOC actually rises.
706770 chargeEnergy := charge * timeSlotDuration * efficiency
771+
772+ // With no usable capacity nothing can be stored; charging is never feasible.
773+ // Guarding here keeps the ±Inf/NaN out of the comparison below.
774+ if mpc .Config .BatteryCapacity <= 0 {
775+ return false
776+ }
777+
707778 newSOC := soc + (chargeEnergy / mpc .Config .BatteryCapacity )
708779 return newSOC <= mpc .Config .BatteryMaxSOC
709780}
@@ -715,12 +786,70 @@ func (mpc *Controller) canDischarge(soc, discharge float64) bool {
715786 timeSlotDuration = 1.0
716787 }
717788
789+ // With no usable capacity there is nothing stored to discharge.
790+ if mpc .Config .BatteryCapacity <= 0 {
791+ return false
792+ }
793+
718794 // Convert power (kW) to energy (kWh) by multiplying by time slot duration
719795 dischargeEnergy := discharge * timeSlotDuration
720796 newSOC := soc - (dischargeEnergy / mpc .Config .BatteryCapacity )
721797 return newSOC >= mpc .Config .BatteryMinSOC
722798}
723799
800+ // batteryEfficiency returns the configured round-trip efficiency, defaulting to 1.0
801+ // (lossless) when it is unset or non-positive.
802+ //
803+ // Several power-balance expressions divide by this value. With an unpopulated config
804+ // it is zero, and `0 / 0` is NaN — which silently propagated into GridImport, Profit
805+ // and ultimately the SOC index, where it surfaced as an out-of-range panic. Defaulting
806+ // mirrors how TimeSlotDuration is already handled throughout this file and keeps every
807+ // returned figure finite.
808+ func (mpc * Controller ) batteryEfficiency () float64 {
809+ if mpc .Config .BatteryEfficiency <= 0 {
810+ return 1.0
811+ }
812+ return mpc .Config .BatteryEfficiency
813+ }
814+
815+ // maxChargePower returns the largest charge power (kW) that can be applied for one
816+ // time slot from soc without exceeding BatteryMaxSOC. It is the exact inverse of the
817+ // SOC update in calculateNewSOC, including the CV/balancing derate, so that clamping
818+ // to this value never saturates. The result is also capped at the hardware limit.
819+ func (mpc * Controller ) maxChargePower (soc , timeSlotDuration float64 ) float64 {
820+ efficiency := 1.0
821+ if mpc .Config .BatteryBalancingSOCThreshold > 0 &&
822+ mpc .Config .BatteryBalancingEfficiencyFactor > 0 &&
823+ soc >= mpc .Config .BatteryBalancingSOCThreshold {
824+ efficiency = mpc .Config .BatteryBalancingEfficiencyFactor
825+ }
826+
827+ headroom := mpc .Config .BatteryMaxSOC - soc
828+ if headroom <= 0 {
829+ return 0
830+ }
831+
832+ return math .Min (
833+ headroom * mpc .Config .BatteryCapacity / (efficiency * timeSlotDuration ),
834+ mpc .Config .BatteryMaxCharge ,
835+ )
836+ }
837+
838+ // maxDischargePower returns the largest discharge power (kW) that can be sustained for
839+ // one time slot from soc without dropping below BatteryMinSOC, capped at the hardware
840+ // limit. Mirrors the SOC update in calculateNewSOC.
841+ func (mpc * Controller ) maxDischargePower (soc , timeSlotDuration float64 ) float64 {
842+ available := soc - mpc .Config .BatteryMinSOC
843+ if available <= 0 {
844+ return 0
845+ }
846+
847+ return math .Min (
848+ available * mpc .Config .BatteryCapacity / timeSlotDuration ,
849+ mpc .Config .BatteryMaxDischarge ,
850+ )
851+ }
852+
724853func (mpc * Controller ) calculateNewSOC (currentSOC , charge , discharge float64 ) float64 {
725854 // Get time slot duration (default to 1 hour if not specified for backward compatibility)
726855 timeSlotDuration := mpc .Config .TimeSlotDuration
@@ -754,13 +883,50 @@ func (mpc *Controller) calculateNewSOC(currentSOC, charge, discharge float64) fl
754883 // Convert power (kW) to energy (kWh) by multiplying by time slot duration
755884 chargeEnergy := charge * timeSlotDuration * efficiency
756885 dischargeEnergy := discharge * timeSlotDuration
886+
887+ // A non-positive capacity means no energy can move in or out of the cells.
888+ // Dividing by it would yield NaN (0/0) or ±Inf and poison the SOC for the
889+ // rest of the horizon — and ultimately the DP index derived from it.
890+ if mpc .Config .BatteryCapacity <= 0 {
891+ return math .Max (mpc .Config .BatteryMinSOC , math .Min (mpc .Config .BatteryMaxSOC , currentSOC ))
892+ }
893+
757894 socChange := (chargeEnergy - dischargeEnergy ) / mpc .Config .BatteryCapacity
758895 newSOC := currentSOC + socChange
759896 return math .Max (mpc .Config .BatteryMinSOC , math .Min (mpc .Config .BatteryMaxSOC , newSOC ))
760897}
761898
899+ // socToIndex maps an SOC value onto its DP table row.
900+ //
901+ // It must never return a value that cannot be used to index the table. Two
902+ // degenerate configurations previously produced one:
903+ //
904+ // - BatteryMaxSOC == BatteryMinSOC makes socStep zero, so the division
905+ // yields NaN (0/0) or ±Inf. Converting NaN to int is implementation
906+ // defined and on amd64/arm64 gives the minimum int64, which panicked as
907+ // soon as it was used as an index.
908+ // - A non-finite soc (e.g. NaN propagated from a zero BatteryCapacity)
909+ // produces the same result.
910+ //
911+ // A zero-width SOC range has exactly one reachable level, so index 0 is the
912+ // correct answer there. A NaN SOC is not a real state and is reported as -1,
913+ // which callers already treat as out of range and skip.
762914func (mpc * Controller ) socToIndex (soc float64 , socStep float64 ) int {
763- return int (math .Floor ((soc - mpc .Config .BatteryMinSOC ) / socStep ))
915+ if ! (socStep > 0 ) {
916+ return 0
917+ }
918+
919+ idx := math .Floor ((soc - mpc .Config .BatteryMinSOC ) / socStep )
920+ switch {
921+ case math .IsNaN (idx ):
922+ return - 1
923+ case math .IsInf (idx , - 1 ):
924+ return - 1
925+ case math .IsInf (idx , 1 ):
926+ return math .MaxInt
927+ }
928+
929+ return int (idx )
764930}
765931
766932func (mpc * Controller ) indexToSOC (index int , socStep float64 ) float64 {
0 commit comments