Skip to content

Commit e0f8d94

Browse files
committed
feat: check avg cell temp to predict preheat load
1 parent 162ded3 commit e0f8d94

10 files changed

Lines changed: 753 additions & 116 deletions

File tree

‎main.go‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,15 @@ func runMPCOptimize(config *scheduler.Config) {
132132
fmt.Printf("Total decisions: %d\n\n", len(decisions))
133133

134134
// Print table header
135-
fmt.Println("┌──────┬─────────────────────┬──────────┬──────────┬──────────┬───────────┬────────────┬────────────┬────────────┬──────────┬────────────┬────────────┬──────────┐")
136-
fmt.Println("│ Hour │ Timestamp │ Batt SOC │ Chr (PV) │ Chr (Grd)│ Bat Disch │ Grid Imprt │ Grid Exprt │ Solar Fcst │ Load Fst │ Imprt Prce │ Exprt Prce │ Profit │")
137-
fmt.Println("│ │ │ (%) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (EUR/MWh) │ (EUR/MWh) │ (EUR) │")
138-
fmt.Println("├──────┼─────────────────────┼──────────┼──────────┼──────────┼───────────┼────────────┼────────────┼────────────┼──────────┼────────────┼────────────┼──────────┤")
135+
fmt.Println("┌──────┬─────────────────────┬──────────┬──────────┬──────────┬───────────┬────────────┬────────────┬────────────┬──────────┬────────────┬────────────┬──────────┬──────────┐")
136+
fmt.Println("│ Hour │ Timestamp │ Batt SOC │ Chr (PV) │ Chr (Grd)│ Bat Disch │ Grid Imprt │ Grid Exprt │ Solar Fcst │ Load Fst │ Imprt Prce │ Exprt Prce │ Bat Temp │ Profit │")
137+
fmt.Println("│ │ │ (%) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (EUR/MWh) │ (EUR/MWh) │ (°C) │ (EUR) │")
138+
fmt.Println("├──────┼─────────────────────┼──────────┼──────────┼──────────┼───────────┼────────────┼────────────┼────────────┼──────────┼────────────┼────────────┼──────────┼──────────┤")
139139

140140
totalProfit := 0.0
141141
for _, dec := range decisions {
142142
timestamp := time.Unix(dec.Timestamp, 0).Format("2006-01-02 15:04")
143-
fmt.Printf("│ %4d │ %19s │ %6.1f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.4f │\n",
143+
fmt.Printf("│ %4d │ %19s │ %6.1f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.2f │ %6.1f │ %6.4f │\n",
144144
dec.Hour,
145145
timestamp,
146146
dec.BatterySOC*100,
@@ -153,12 +153,13 @@ func runMPCOptimize(config *scheduler.Config) {
153153
dec.LoadForecast,
154154
dec.ImportPrice*1000,
155155
dec.ExportPrice*1000,
156+
dec.BatteryAvgCellTemp,
156157
dec.Profit,
157158
)
158159
totalProfit += dec.Profit
159160
}
160161

161-
fmt.Println("└──────┴─────────────────────┴──────────┴──────────┴──────────┴───────────┴────────────┴────────────┴────────────┴──────────┴────────────┴────────────┴──────────┘")
162+
fmt.Println("└──────┴─────────────────────┴──────────┴──────────┴──────────┴───────────┴────────────┴────────────┴────────────┴──────────┴────────────┴────────────┴──────────┴──────────┘")
162163
fmt.Println("\n========================================")
163164
fmt.Println("SUMMARY")
164165
fmt.Println("========================================")

‎mpc/mpc.go‎

Lines changed: 92 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,30 @@ import (
77

88
// SystemConfig holds the inverter system configuration
99
type SystemConfig struct {
10-
BatteryCapacity float64 // kWh
11-
BatteryMaxCharge float64 // kW
12-
BatteryMaxDischarge float64 // kW
13-
BatteryMinSOC float64 // percentage (0-1)
14-
BatteryMaxSOC float64 // percentage (0-1)
15-
BatteryEfficiency float64 // round-trip efficiency (0-1)
16-
BatteryDegradationCost float64 // $/kWh cycled
17-
MaxGridImport float64 // kW
18-
MaxGridExport float64 // kW
10+
BatteryCapacity float64 // kWh
11+
BatteryMaxCharge float64 // kW
12+
BatteryMaxDischarge float64 // kW
13+
BatteryMinSOC float64 // percentage (0-1)
14+
BatteryMaxSOC float64 // percentage (0-1)
15+
BatteryEfficiency float64 // round-trip efficiency (0-1)
16+
BatteryDegradationCost float64 // $/kWh cycled
17+
MaxGridImport float64 // kW
18+
MaxGridExport float64 // kW
19+
BatteryPreHeatPower float64 // kW - power consumption of battery preheating when active
20+
BatteryPreHeatTempThreshold float64 // °C - temperature threshold below which battery preheating activates
21+
BatteryThermalTimeConstant float64 // fraction per time slot - rate at which battery temperature approaches air temperature (0-1)
1922
}
2023

2124
// TimeSlot represents one time period of operation (typically 15 minutes, configurable via check_price_interval)
2225
type TimeSlot struct {
23-
Hour int
24-
Timestamp int64 // Unix timestamp when this time slot begins
25-
ImportPrice float64 // $/kWh
26-
ExportPrice float64 // $/kWh
27-
SolarForecast float64 // kW average for the time period
28-
LoadForecast float64 // kW average for the time period
29-
CloudCoverage float64 // % cloud coverage (0-100)
30-
WeatherSymbol string // weather condition symbol
26+
Hour int
27+
Timestamp int64 // Unix timestamp when this time slot begins
28+
ImportPrice float64 // $/kWh
29+
ExportPrice float64 // $/kWh
30+
SolarForecast float64 // kW average for the time period
31+
LoadForecast float64 // kW average for the time period
32+
CloudCoverage float64 // % cloud coverage (0-100)
33+
WeatherSymbol string // weather condition symbol
3134
AirTemperature float64 // °C air temperature
3235
}
3336

@@ -43,6 +46,7 @@ type ControlDecision struct {
4346
GridExport float64 // kW (positive = exporting)
4447
BatterySOC float64 // percentage (0-1)
4548
Profit float64 // $ for this time period
49+
BatteryPreHeatActive bool // true if battery preheating is active during this time slot
4650
// Forecast data used for this decision
4751
ImportPrice float64 // $/kWh
4852
ExportPrice float64 // $/kWh
@@ -56,17 +60,19 @@ type ControlDecision struct {
5660

5761
// Controller implements Model Predictive Control
5862
type Controller struct {
59-
Config SystemConfig
60-
Horizon int // number of time periods to look ahead
61-
CurrentSOC float64
63+
Config SystemConfig
64+
Horizon int // number of time periods to look ahead
65+
CurrentSOC float64
66+
CurrentBatteryTemp float64 // °C current battery temperature
6267
}
6368

6469
// NewController creates a new MPC controller
6570
func NewController(config SystemConfig, horizon int, initialSOC float64) *Controller {
6671
return &Controller{
67-
Config: config,
68-
Horizon: horizon,
69-
CurrentSOC: initialSOC,
72+
Config: config,
73+
Horizon: horizon,
74+
CurrentSOC: initialSOC,
75+
CurrentBatteryTemp: 20.0, // Default to room temperature
7076
}
7177
}
7278

@@ -111,11 +117,12 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
111117
socSteps := 500
112118
socStep := (mpc.Config.BatteryMaxSOC - mpc.Config.BatteryMinSOC) / float64(socSteps)
113119

114-
// DP table: [time][soc_index] -> (best_profit, best_decision)
120+
// DP table: [time][soc_index] -> (best_profit, best_decision, battery_temp)
115121
type dpState struct {
116-
profit float64
117-
decision ControlDecision
118-
prevSOC int
122+
profit float64
123+
decision ControlDecision
124+
prevSOC int
125+
batteryTemp float64 // °C battery temperature at this state
119126
}
120127

121128
dp := make([][]dpState, len(forecast)+1)
@@ -126,9 +133,10 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
126133
}
127134
}
128135

129-
// Initialize with current SOC
136+
// Initialize with current SOC and battery temperature
130137
startSOCIndex := mpc.socToIndex(mpc.CurrentSOC, socStep)
131138
dp[0][startSOCIndex].profit = 0
139+
dp[0][startSOCIndex].batteryTemp = mpc.CurrentBatteryTemp
132140

133141
// Forward pass - build DP table
134142
for t := range forecast {
@@ -143,9 +151,10 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
143151
}
144152

145153
currentSOC := mpc.indexToSOC(socIdx, socStep)
154+
currentBatteryTemp := dp[t][socIdx].batteryTemp
146155

147156
// Try different control decisions
148-
decisions := mpc.generateFeasibleDecisions(currentSOC, slot)
157+
decisions := mpc.generateFeasibleDecisions(currentSOC, currentBatteryTemp, slot)
149158

150159
for _, dec := range decisions {
151160
newSOC := mpc.calculateNewSOC(currentSOC, dec.BatteryCharge, dec.BatteryDischarge)
@@ -155,6 +164,9 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
155164
continue
156165
}
157166

167+
// Calculate next battery temperature based on this decision
168+
newBatteryTemp := mpc.calculateNextBatteryTemp(currentBatteryTemp, slot.AirTemperature, dec.BatteryCharge > 0, dec.BatteryPreHeatActive)
169+
158170
profit := mpc.calculateProfit(dec, slot)
159171
totalProfit := dp[t][socIdx].profit + profit
160172

@@ -171,7 +183,9 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
171183
dp[t+1][newSOCIdx].decision.CloudCoverage = slot.CloudCoverage
172184
dp[t+1][newSOCIdx].decision.WeatherSymbol = slot.WeatherSymbol
173185
dp[t+1][newSOCIdx].decision.AirTemperature = slot.AirTemperature
186+
dp[t+1][newSOCIdx].decision.BatteryAvgCellTemp = currentBatteryTemp
174187
dp[t+1][newSOCIdx].prevSOC = socIdx
188+
dp[t+1][newSOCIdx].batteryTemp = newBatteryTemp
175189
}
176190
}
177191
}
@@ -199,10 +213,33 @@ func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bo
199213
return path
200214
}
201215

216+
// calculateNextBatteryTemp calculates the battery temperature for the next time slot
217+
// based on current temperature, air temperature, and whether the battery is charging
218+
func (mpc *Controller) calculateNextBatteryTemp(currentTemp, airTemp float64, isCharging, isPreHeating bool) float64 {
219+
if isCharging && isPreHeating {
220+
// When charging with preheat, battery maintains temperature at threshold
221+
return math.Max(currentTemp, mpc.Config.BatteryPreHeatTempThreshold)
222+
}
223+
224+
// When not charging or warm enough, battery temperature moves toward air temperature
225+
// T(t+1) = T(t) + k * (T_air - T(t))
226+
// This models natural cooling/heating toward ambient air temperature
227+
tempDiff := airTemp - currentTemp
228+
return currentTemp + mpc.Config.BatteryThermalTimeConstant*tempDiff
229+
}
230+
202231
// generateFeasibleDecisions creates a set of feasible control decisions
203-
func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, slot TimeSlot) []ControlDecision {
232+
func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, currentBatteryTemp float64, slot TimeSlot) []ControlDecision {
204233
decisions := []ControlDecision{}
205234

235+
// Determine if battery preheating would be needed based on battery temperature
236+
// Battery preheating is only active when actually charging the battery
237+
needsPreHeat := mpc.Config.BatteryPreHeatPower > 0 && currentBatteryTemp < mpc.Config.BatteryPreHeatTempThreshold
238+
preHeatPower := 0.0
239+
if needsPreHeat {
240+
preHeatPower = mpc.Config.BatteryPreHeatPower
241+
}
242+
206243
// Always include idle option
207244
batteryActions := []struct {
208245
charge float64
@@ -242,16 +279,28 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, slot TimeSl
242279

243280
// For each battery action, calculate power balance
244281
for _, action := range batteryActions {
282+
// Battery preheating is only active when we're actually charging and temp is below threshold
283+
preHeatActive := needsPreHeat && action.charge > 0
284+
245285
dec := ControlDecision{
246-
Hour: slot.Hour,
247-
Timestamp: slot.Timestamp,
248-
BatteryCharge: action.charge,
249-
BatteryDischarge: action.discharge,
286+
Hour: slot.Hour,
287+
Timestamp: slot.Timestamp,
288+
BatteryCharge: action.charge,
289+
BatteryDischarge: action.discharge,
290+
BatteryPreHeatActive: preHeatActive,
250291
}
251292

252-
// Power balance: Solar + GridImport + BatteryDischarge = Load + GridExport + BatteryCharge
293+
// Power balance: Solar + GridImport + BatteryDischarge = Load + GridExport + BatteryCharge + BatteryPreHeat
294+
// When battery preheating is active (battery is charging at low temp), it consumes extra power from the grid
253295
netSolar := slot.SolarForecast
254-
netLoad := slot.LoadForecast + action.charge/mpc.Config.BatteryEfficiency
296+
extraLoad := 0.0
297+
298+
// Battery preheating only consumes power when battery is charging
299+
if preHeatActive {
300+
extraLoad = preHeatPower
301+
}
302+
303+
netLoad := slot.LoadForecast + action.charge/mpc.Config.BatteryEfficiency + extraLoad
255304
netSupply := netSolar + action.discharge*mpc.Config.BatteryEfficiency
256305

257306
balance := netSupply - netLoad
@@ -276,14 +325,15 @@ func (mpc *Controller) generateFeasibleDecisions(currentSOC float64, slot TimeSl
276325
}
277326

278327
// calculateProfit computes the profit for a decision
279-
// The power balance equation ensures: Solar + GridImport + BatteryDischarge*eff = Load + GridExport + BatteryCharge/eff
280-
// Therefore, GridImport and GridExport already reflect the effect of battery operations.
328+
// The power balance equation ensures: Solar + GridImport + BatteryDischarge*eff = Load + GridExport + BatteryCharge/eff + BatteryPreHeat
329+
// Therefore, GridImport and GridExport already reflect the effect of battery operations and battery preheating.
281330
// Profit is simply: revenue from exports - cost of imports - degradation cost
331+
// Note: The battery preheating cost is already included in GridImport when battery is charging at low temperatures
282332
func (mpc *Controller) calculateProfit(dec ControlDecision, slot TimeSlot) float64 {
283333
// Revenue from exporting to grid
284334
revenue := dec.GridExport * slot.ExportPrice
285335

286-
// Cost of importing from grid
336+
// Cost of importing from grid (already includes battery preheating consumption when active)
287337
importCost := dec.GridImport * slot.ImportPrice
288338

289339
// Battery degradation cost (wear and tear from cycling)
@@ -292,13 +342,15 @@ func (mpc *Controller) calculateProfit(dec ControlDecision, slot TimeSlot) float
292342

293343
// Net profit:
294344
// + Revenue from exports (GridExport already accounts for battery discharge to grid)
295-
// - Cost of imports (GridImport already accounts for battery charging and reduced imports from discharge)
345+
// - Cost of imports (GridImport already accounts for battery charging, battery preheating, and reduced imports from discharge)
296346
// - Battery degradation (wear and tear cost)
297347
//
298348
// This correctly incentivizes arbitrage:
299349
// - Charging at low import prices reduces profit by importCost
300350
// - Discharging at high export prices increases profit by revenue
351+
// - When battery temp is low (<10°C), charging incurs additional battery preheating cost (700W)
301352
// - The DP optimizer will naturally prefer charge-low/discharge-high strategies
353+
// - The optimizer will avoid charging at low temperatures unless prices are very favorable
302354
profit := revenue - importCost - degradationCost
303355

304356
return profit

0 commit comments

Comments
 (0)