Skip to content

Commit 44454ec

Browse files
committed
feat: use self-consumption mode for PV only charging
1 parent 45b3896 commit 44454ec

8 files changed

Lines changed: 220 additions & 66 deletions

File tree

‎main.go‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,20 @@ 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 │ Bat Chr │ Bat Disch │ Grid Imprt │ Grid Exprt │ Solar Fcst │ Load Fst │ Imprt Prce │ Exprt Prce │ Profit │")
137-
fmt.Println("│ │ │ (%) │ (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 │ Profit │")
137+
fmt.Println("│ │ │ (%) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (EUR/MWh) │ (EUR/MWh) │ (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 │ %5.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.4f │\n",
144144
dec.Hour,
145145
timestamp,
146146
dec.BatterySOC*100,
147-
dec.BatteryCharge,
147+
dec.BatteryChargeFromPV,
148+
dec.BatteryChargeFromGrid,
148149
dec.BatteryDischarge,
149150
dec.GridImport,
150151
dec.GridExport,
@@ -157,7 +158,7 @@ func runMPCOptimize(config *scheduler.Config) {
157158
totalProfit += dec.Profit
158159
}
159160

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

‎mpc/mpc.go‎

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,16 @@ type TimeSlot struct {
3232

3333
// ControlDecision represents the optimal control for one time slot
3434
type ControlDecision struct {
35-
Hour int
36-
Timestamp int64 // Unix timestamp when this time slot begins
37-
BatteryCharge float64 // kW (positive = charging)
38-
BatteryDischarge float64 // kW (positive = discharging)
39-
GridImport float64 // kW (positive = importing)
40-
GridExport float64 // kW (positive = exporting)
41-
BatterySOC float64 // percentage (0-1)
42-
Profit float64 // $ for this hour
35+
Hour int
36+
Timestamp int64 // Unix timestamp when this time slot begins
37+
BatteryCharge float64 // kW (positive = charging) - DEPRECATED: use BatteryChargeFromPV + BatteryChargeFromGrid
38+
BatteryChargeFromPV float64 // kW (positive = charging from PV surplus)
39+
BatteryChargeFromGrid float64 // kW (positive = charging from grid)
40+
BatteryDischarge float64 // kW (positive = discharging)
41+
GridImport float64 // kW (positive = importing)
42+
GridExport float64 // kW (positive = exporting)
43+
BatterySOC float64 // percentage (0-1)
44+
Profit float64 // $ for this hour
4345
// Forecast data used for this decision
4446
ImportPrice float64 // $/kWh
4547
ExportPrice float64 // $/kWh
@@ -66,11 +68,39 @@ func NewController(config SystemConfig, horizon int, initialSOC float64) *Contro
6668
}
6769

6870
// Optimize finds the optimal control strategy using dynamic programming
71+
// It runs two optimizations: one with solar forecast and one without (grid-only)
72+
// Then splits the BatteryCharge into BatteryChargeFromPV and BatteryChargeFromGrid
6973
func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
7074
if len(forecast) == 0 {
7175
return nil
7276
}
7377

78+
// Run optimization with full solar forecast
79+
decisionsWithSolar := mpc.optimizeWithForecast(forecast, true)
80+
81+
// Run optimization without solar (grid-only scenario)
82+
decisionsWithoutSolar := mpc.optimizeWithForecast(forecast, false)
83+
84+
// Combine results: split BatteryCharge into PV and Grid components
85+
finalDecisions := make([]ControlDecision, len(decisionsWithSolar))
86+
for i := range decisionsWithSolar {
87+
finalDecisions[i] = decisionsWithSolar[i]
88+
89+
// BatteryChargeFromPV is the charge power when solar is available
90+
finalDecisions[i].BatteryChargeFromPV = decisionsWithSolar[i].BatteryCharge
91+
92+
// BatteryChargeFromGrid is the charge power when solar is zero
93+
finalDecisions[i].BatteryChargeFromGrid = decisionsWithoutSolar[i].BatteryCharge
94+
95+
// Keep total BatteryCharge for backward compatibility
96+
finalDecisions[i].BatteryCharge = decisionsWithSolar[i].BatteryCharge
97+
}
98+
99+
return finalDecisions
100+
}
101+
102+
// optimizeWithForecast performs the actual optimization with optional solar forecast
103+
func (mpc *Controller) optimizeWithForecast(forecast []TimeSlot, includeSolar bool) []ControlDecision {
74104
// Use dynamic programming for optimization
75105
// State: SOC level, Time: hour
76106
// We'll discretize SOC into steps for tractability
@@ -100,6 +130,9 @@ func (mpc *Controller) Optimize(forecast []TimeSlot) []ControlDecision {
100130
// Forward pass - build DP table
101131
for t := range forecast {
102132
slot := forecast[t]
133+
if !includeSolar {
134+
slot.SolarForecast = 0
135+
}
103136

104137
for socIdx := 0; socIdx <= socSteps; socIdx++ {
105138
if math.IsInf(dp[t][socIdx].profit, -1) {

‎scheduler/mpc.go‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -450,8 +450,8 @@ func (s *MinerScheduler) estimateLoadForecast(hourlyPrice float64, priceLimit fl
450450
// executeMPCDecision executes the first MPC control decision
451451
func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRun bool) error {
452452
if dryRun {
453-
s.logger.Printf("DRY-RUN: Would execute MPC decision - Charge: %.1f kW, Discharge: %.1f kW, Import: %.1f kW, Export: %.1f kW",
454-
decision.BatteryCharge, decision.BatteryDischarge, decision.GridImport, decision.GridExport)
453+
s.logger.Printf("DRY-RUN: Would execute MPC decision - ChargeFromPV: %.1f kW, ChargeFromGrid: %.1f kW, Discharge: %.1f kW, Import: %.1f kW, Export: %.1f kW",
454+
decision.BatteryChargeFromPV, decision.BatteryChargeFromGrid, decision.BatteryDischarge, decision.GridImport, decision.GridExport)
455455
return nil
456456
}
457457

@@ -473,12 +473,23 @@ func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRu
473473
// Determine control mode based on decision
474474
var mode uint16
475475

476-
if decision.BatteryCharge > 0.01 {
476+
if decision.BatteryChargeFromPV > 0.01 || decision.BatteryChargeFromGrid > 0.01 {
477477
// Battery should charge
478-
// Mode 4: Command charging (PV first) - charge from PV, then grid if needed
479-
mode = 4
480-
chargeLimit := decision.BatteryCharge
481-
s.logger.Printf("Setting battery to CHARGE mode: %.1f kW", chargeLimit)
478+
// Use BatteryChargeFromPV as the charge limit
479+
chargeLimit := decision.BatteryChargeFromPV
480+
481+
// Decide mode based on whether grid charging is needed
482+
if decision.BatteryChargeFromGrid > 0.01 {
483+
// Mode 4: Command charging (PV first, then grid) - charge from PV and grid if needed
484+
mode = 4
485+
s.logger.Printf("Setting battery to CHARGE mode (PV + Grid): ChargeFromPV: %.1f kW, ChargeFromGrid: %.1f kW",
486+
decision.BatteryChargeFromPV, decision.BatteryChargeFromGrid)
487+
} else {
488+
// Mode 2: Self-use mode - charge from PV surplus only
489+
mode = 2
490+
s.logger.Printf("Setting battery to CHARGE mode (PV only): ChargeFromPV: %.1f kW",
491+
decision.BatteryChargeFromPV)
492+
}
482493

483494
// Set Remote EMS control mode
484495
if err := client.SetRemoteEMSMode(mode); err != nil {
@@ -530,8 +541,8 @@ func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRu
530541
}
531542
}
532543

533-
s.logger.Printf("Successfully executed MPC decision - Mode: %d, SOC: %.1f%%, GridImport: %.1f kW, GridExport: %.1f kW",
534-
mode, decision.BatterySOC*100, decision.GridImport, decision.GridExport)
544+
s.logger.Printf("Successfully executed MPC decision - Mode: %d, SOC: %.1f%%, ChargeFromPV: %.1f kW, ChargeFromGrid: %.1f kW, Discharge: %.1f kW, GridImport: %.1f kW, GridExport: %.1f kW",
545+
mode, decision.BatterySOC*100, decision.BatteryChargeFromPV, decision.BatteryChargeFromGrid, decision.BatteryDischarge, decision.GridImport, decision.GridExport)
535546

536547
return nil
537548
}

‎scheduler/mpc_persistence.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ func (s *MinerScheduler) saveMPCDecisions(ctx context.Context, decisions []mpc.C
4545
timestamp,
4646
hour,
4747
battery_charge,
48+
battery_charge_from_pv,
49+
battery_charge_from_grid,
4850
battery_discharge,
4951
grid_import,
5052
grid_export,
@@ -56,10 +58,12 @@ func (s *MinerScheduler) saveMPCDecisions(ctx context.Context, decisions []mpc.C
5658
load_forecast,
5759
cloud_coverage,
5860
weather_symbol
59-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
61+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
6062
ON CONFLICT (timestamp) DO UPDATE SET
6163
hour = EXCLUDED.hour,
6264
battery_charge = EXCLUDED.battery_charge,
65+
battery_charge_from_pv = EXCLUDED.battery_charge_from_pv,
66+
battery_charge_from_grid = EXCLUDED.battery_charge_from_grid,
6367
battery_discharge = EXCLUDED.battery_discharge,
6468
grid_import = EXCLUDED.grid_import,
6569
grid_export = EXCLUDED.grid_export,
@@ -83,6 +87,8 @@ func (s *MinerScheduler) saveMPCDecisions(ctx context.Context, decisions []mpc.C
8387
decision.Timestamp,
8488
decision.Hour,
8589
decision.BatteryCharge,
90+
decision.BatteryChargeFromPV,
91+
decision.BatteryChargeFromGrid,
8692
decision.BatteryDischarge,
8793
decision.GridImport,
8894
decision.GridExport,
@@ -137,6 +143,8 @@ func (s *MinerScheduler) loadLatestMPCDecisions(ctx context.Context) ([]mpc.Cont
137143
timestamp,
138144
hour,
139145
battery_charge,
146+
battery_charge_from_pv,
147+
battery_charge_from_grid,
140148
battery_discharge,
141149
grid_import,
142150
grid_export,
@@ -167,6 +175,8 @@ func (s *MinerScheduler) loadLatestMPCDecisions(ctx context.Context) ([]mpc.Cont
167175
&decision.Timestamp,
168176
&decision.Hour,
169177
&decision.BatteryCharge,
178+
&decision.BatteryChargeFromPV,
179+
&decision.BatteryChargeFromGrid,
170180
&decision.BatteryDischarge,
171181
&decision.GridImport,
172182
&decision.GridExport,

‎scheduler/mpc_persistence_test.go‎

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -44,36 +44,40 @@ func TestMPCPersistence_SaveAndLoad(t *testing.T) {
4444
now := time.Now().Unix()
4545
decisions := []mpc.ControlDecision{
4646
{
47-
Hour: 0,
48-
Timestamp: now + 3600,
49-
BatteryCharge: 10.5,
50-
BatteryDischarge: 0,
51-
GridImport: 5.0,
52-
GridExport: 0,
53-
BatterySOC: 0.6,
54-
Profit: 2.5,
55-
ImportPrice: 0.1,
56-
ExportPrice: 0.05,
57-
SolarForecast: 15.0,
58-
LoadForecast: 10.0,
59-
CloudCoverage: 30.0,
60-
WeatherSymbol: "clearsky_day",
47+
Hour: 0,
48+
Timestamp: now + 3600,
49+
BatteryCharge: 10.5,
50+
BatteryChargeFromPV: 10.5,
51+
BatteryChargeFromGrid: 5.0,
52+
BatteryDischarge: 0,
53+
GridImport: 5.0,
54+
GridExport: 0,
55+
BatterySOC: 0.6,
56+
Profit: 2.5,
57+
ImportPrice: 0.1,
58+
ExportPrice: 0.05,
59+
SolarForecast: 15.0,
60+
LoadForecast: 10.0,
61+
CloudCoverage: 30.0,
62+
WeatherSymbol: "clearsky_day",
6163
},
6264
{
63-
Hour: 1,
64-
Timestamp: now + 7200,
65-
BatteryCharge: 0,
66-
BatteryDischarge: 8.0,
67-
GridImport: 0,
68-
GridExport: 3.0,
69-
BatterySOC: 0.5,
70-
Profit: 3.2,
71-
ImportPrice: 0.12,
72-
ExportPrice: 0.06,
73-
SolarForecast: 20.0,
74-
LoadForecast: 12.0,
75-
CloudCoverage: 10.0,
76-
WeatherSymbol: "fair_day",
65+
Hour: 1,
66+
Timestamp: now + 7200,
67+
BatteryCharge: 0,
68+
BatteryChargeFromPV: 0,
69+
BatteryChargeFromGrid: 0,
70+
BatteryDischarge: 8.0,
71+
GridImport: 0,
72+
GridExport: 3.0,
73+
BatterySOC: 0.5,
74+
Profit: 3.2,
75+
ImportPrice: 0.12,
76+
ExportPrice: 0.06,
77+
SolarForecast: 20.0,
78+
LoadForecast: 12.0,
79+
CloudCoverage: 10.0,
80+
WeatherSymbol: "fair_day",
7781
},
7882
}
7983

@@ -103,6 +107,12 @@ func TestMPCPersistence_SaveAndLoad(t *testing.T) {
103107
if decision.BatteryCharge != decisions[i].BatteryCharge {
104108
t.Errorf("Decision %d: expected battery_charge %.2f, got %.2f", i, decisions[i].BatteryCharge, decision.BatteryCharge)
105109
}
110+
if decision.BatteryChargeFromPV != decisions[i].BatteryChargeFromPV {
111+
t.Errorf("Decision %d: expected battery_charge_from_pv %.2f, got %.2f", i, decisions[i].BatteryChargeFromPV, decision.BatteryChargeFromPV)
112+
}
113+
if decision.BatteryChargeFromGrid != decisions[i].BatteryChargeFromGrid {
114+
t.Errorf("Decision %d: expected battery_charge_from_grid %.2f, got %.2f", i, decisions[i].BatteryChargeFromGrid, decision.BatteryChargeFromGrid)
115+
}
106116
if decision.Profit != decisions[i].Profit {
107117
t.Errorf("Decision %d: expected profit %.2f, got %.2f", i, decisions[i].Profit, decision.Profit)
108118
}
@@ -238,10 +248,10 @@ func TestMPCPersistence_LoadOnlyFutureDecisions(t *testing.T) {
238248
// Insert directly to test load filtering
239249
for _, d := range decisions {
240250
_, err := db.Exec(`
241-
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_discharge,
242-
grid_import, grid_export, battery_soc, profit, import_price, export_price,
243-
solar_forecast, load_forecast)
244-
VALUES ($1, $2, 0, 0, 0, 0, 0.5, $3, 0.1, 0.05, 10, 5)
251+
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_charge_from_pv,
252+
battery_charge_from_grid, battery_discharge, grid_import, grid_export, battery_soc,
253+
profit, import_price, export_price, solar_forecast, load_forecast)
254+
VALUES ($1, $2, 0, 0, 0, 0, 0, 0, 0.5, $3, 0.1, 0.05, 10, 5)
245255
`, d.Timestamp, d.Hour, d.Profit)
246256
if err != nil {
247257
t.Fatalf("Failed to insert decision: %v", err)
@@ -299,21 +309,21 @@ func TestMPCPersistence_UniqueTimestamp(t *testing.T) {
299309

300310
// Insert first decision
301311
_, err = db.Exec(`
302-
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_discharge,
303-
grid_import, grid_export, battery_soc, profit, import_price, export_price,
304-
solar_forecast, load_forecast)
305-
VALUES ($1, 0, 10, 0, 5, 0, 0.6, 2.5, 0.1, 0.05, 15, 10)
312+
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_charge_from_pv,
313+
battery_charge_from_grid, battery_discharge, grid_import, grid_export, battery_soc,
314+
profit, import_price, export_price, solar_forecast, load_forecast)
315+
VALUES ($1, 0, 10, 10, 0, 0, 5, 0, 0.6, 2.5, 0.1, 0.05, 15, 10)
306316
`, timestamp)
307317
if err != nil {
308318
t.Fatalf("Failed to insert first decision: %v", err)
309319
}
310320

311321
// Try to insert duplicate timestamp (should be handled by UPSERT in saveMPCDecisions)
312322
_, err = db.Exec(`
313-
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_discharge,
314-
grid_import, grid_export, battery_soc, profit, import_price, export_price,
315-
solar_forecast, load_forecast)
316-
VALUES ($1, 1, 20, 0, 10, 0, 0.7, 5.0, 0.12, 0.06, 20, 12)
323+
INSERT INTO mpc_decisions (timestamp, hour, battery_charge, battery_charge_from_pv,
324+
battery_charge_from_grid, battery_discharge, grid_import, grid_export, battery_soc,
325+
profit, import_price, export_price, solar_forecast, load_forecast)
326+
VALUES ($1, 1, 20, 20, 0, 0, 10, 0, 0.7, 5.0, 0.12, 0.06, 20, 12)
317327
ON CONFLICT (timestamp) DO UPDATE SET
318328
hour = EXCLUDED.hour,
319329
profit = EXCLUDED.profit
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Migration: Add battery_charge_from_pv and battery_charge_from_grid columns
2+
-- This migration splits battery_charge into two components:
3+
-- 1. battery_charge_from_pv: charge power based on current solar forecast
4+
-- 2. battery_charge_from_grid: charge power based on zero solar forecast (grid-only scenario)
5+
6+
-- Add new columns with default values
7+
ALTER TABLE mpc_decisions
8+
ADD COLUMN IF NOT EXISTS battery_charge_from_pv NUMERIC NOT NULL DEFAULT 0;
9+
10+
ALTER TABLE mpc_decisions
11+
ADD COLUMN IF NOT EXISTS battery_charge_from_grid NUMERIC NOT NULL DEFAULT 0;
12+
13+
-- For existing rows, copy battery_charge to battery_charge_from_pv
14+
-- (assuming existing data represents PV-based charging)
15+
UPDATE mpc_decisions
16+
SET battery_charge_from_pv = battery_charge,
17+
battery_charge_from_grid = 0
18+
WHERE battery_charge_from_pv = 0 AND battery_charge_from_grid = 0;

0 commit comments

Comments
 (0)