@@ -7,27 +7,30 @@ import (
77
88// SystemConfig holds the inverter system configuration
99type 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)
2225type 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
5862type 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
6570func 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
282332func (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