Skip to content

Commit 98849cd

Browse files
committed
fix: nightly charging
1 parent 4e73abe commit 98849cd

2 files changed

Lines changed: 489 additions & 70 deletions

File tree

‎mpc/mpc.go‎

Lines changed: 215 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
134149
func (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+
724853
func (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.
762914
func (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

766932
func (mpc *Controller) indexToSOC(index int, socStep float64) float64 {

0 commit comments

Comments
 (0)