diff --git a/main.go b/main.go
index f470ae8..336e8b5 100644
--- a/main.go
+++ b/main.go
@@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"syscall"
+ "time"
"github.com/devskill-org/ems/scheduler"
"github.com/devskill-org/ems/sigenergy"
@@ -20,6 +21,7 @@ func main() {
info = flag.Bool("info", false, "Show Plant Information")
help = flag.Bool("help", false, "Show help message")
serverOnly = flag.Bool("serverOnly", false, "Run only web server without periodic checks")
+ mpc = flag.Bool("mpc", false, "Run MPC optimization once and log all decisions")
)
flag.Parse()
@@ -41,6 +43,12 @@ func main() {
}
return
}
+
+ if *mpc {
+ runMPCOptimize(config)
+ return
+ }
+
fmt.Printf("Starting Energy Management System with the following configuration:\n")
fmt.Printf(" Price Limit: %.2f EUR/MWh\n", config.PriceLimit)
fmt.Printf(" Network: %s\n", config.Network)
@@ -91,6 +99,67 @@ func main() {
logger.Printf("Scheduler stopped successfully")
}
+func runMPCOptimize(config *scheduler.Config) {
+ logger := log.New(os.Stdout, "[MPC] ", log.LstdFlags)
+
+ // Create scheduler (needed for MPC functionality)
+ minerScheduler := scheduler.NewMinerSchedulerWithHealthCheck(config, logger)
+
+ ctx := context.Background()
+
+ minerScheduler.RunMinerDiscovery(ctx)
+
+ // Run MPC optimization
+ logger.Printf("Running MPC optimization...")
+ minerScheduler.RunMPCOptimize(ctx)
+
+ // Get and log all decisions
+ decisions := minerScheduler.GetMPCDecisions()
+ if len(decisions) == 0 {
+ logger.Printf("No MPC decisions were generated")
+ return
+ }
+
+ fmt.Println("\n========================================")
+ fmt.Println("MPC OPTIMIZATION RESULTS")
+ fmt.Println("========================================")
+ fmt.Printf("Total decisions: %d\n\n", len(decisions))
+
+ // Print table header
+ fmt.Println("┌──────┬─────────────────────┬──────────┬─────────┬───────────┬────────────┬────────────┬────────────┬──────────┬────────────┬────────────┬──────────┐")
+ fmt.Println("│ Hour │ Timestamp │ Batt SOC │ Bat Chr │ Bat Disch │ Grid Imprt │ Grid Exprt │ Solar Fcst │ Load Fst │ Imprt Prce │ Exprt Prce │ Profit │")
+ fmt.Println("│ │ │ (%) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (kW) │ (EUR/MWh) │ (EUR/MWh) │ (EUR) │")
+ fmt.Println("├──────┼─────────────────────┼──────────┼─────────┼───────────┼────────────┼────────────┼────────────┼──────────┼────────────┼────────────┼──────────┤")
+
+ totalProfit := 0.0
+ for _, dec := range decisions {
+ timestamp := time.Unix(dec.Timestamp, 0).Format("2006-01-02 15:04")
+ 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",
+ dec.Hour,
+ timestamp,
+ dec.BatterySOC*100,
+ dec.BatteryCharge,
+ dec.BatteryDischarge,
+ dec.GridImport,
+ dec.GridExport,
+ dec.SolarForecast,
+ dec.LoadForecast,
+ dec.ImportPrice*1000,
+ dec.ExportPrice*1000,
+ dec.Profit,
+ )
+ totalProfit += dec.Profit
+ }
+
+ fmt.Println("└──────┴─────────────────────┴──────────┴─────────┴───────────┴────────────┴────────────┴────────────┴──────────┴────────────┴────────────┴──────────┘")
+ fmt.Println("\n========================================")
+ fmt.Println("SUMMARY")
+ fmt.Println("========================================")
+ fmt.Printf("Total expected profit: %.2f EUR\n", totalProfit)
+ fmt.Printf("Optimization horizon: %d hours\n", len(decisions))
+ fmt.Println("========================================")
+}
+
func showHelp() {
fmt.Println("Energy Management System (EMS) - Optimize energy consumption, production, and storage")
fmt.Println()
@@ -126,6 +195,9 @@ func showHelp() {
fmt.Println(" # Run only web server without periodic checks")
fmt.Println(" ems -serverOnly")
fmt.Println()
+ fmt.Println(" # Run MPC optimization once and log all decisions")
+ fmt.Println(" ems -mpc")
+ fmt.Println()
fmt.Println(" # Show this help")
fmt.Println(" ems -help")
}
diff --git a/mpc/mpc.go b/mpc/mpc.go
index fcb5d74..7da3cce 100644
--- a/mpc/mpc.go
+++ b/mpc/mpc.go
@@ -73,7 +73,8 @@ func (mpc *MPCController) Optimize(forecast []TimeSlot) []ControlDecision {
// Use dynamic programming for optimization
// State: SOC level, Time: hour
// We'll discretize SOC into steps for tractability
- socSteps := 200
+ // Use finer granularity for better precision in finding optimal discharge timing
+ socSteps := 500
socStep := (mpc.Config.BatteryMaxSOC - mpc.Config.BatteryMinSOC) / float64(socSteps)
// DP table: [time][soc_index] -> (best_profit, best_decision)
@@ -139,6 +140,7 @@ func (mpc *MPCController) Optimize(forecast []TimeSlot) []ControlDecision {
}
// Backward pass - reconstruct optimal path
+ // Prefer paths that end with lower SOC (use more battery for arbitrage)
bestFinalSOC := 0
bestFinalProfit := math.Inf(-1)
for socIdx := 0; socIdx <= socSteps; socIdx++ {
@@ -163,7 +165,7 @@ func (mpc *MPCController) Optimize(forecast []TimeSlot) []ControlDecision {
func (mpc *MPCController) generateFeasibleDecisions(currentSOC float64, slot TimeSlot) []ControlDecision {
decisions := []ControlDecision{}
- // Discretize battery actions (charge, discharge, idle)
+ // Always include idle option
batteryActions := []struct {
charge float64
discharge float64
@@ -171,9 +173,16 @@ func (mpc *MPCController) generateFeasibleDecisions(currentSOC float64, slot Tim
{0, 0}, // Idle
}
- // Add charge options (more granular for better arbitrage opportunities)
- for i := 1; i <= 10; i++ {
- charge := float64(i) * mpc.Config.BatteryMaxCharge / 10.0
+ // For better arbitrage, focus on key power levels:
+ // 1. Maximum power (for concentrated operations)
+ // 2. A few intermediate levels (for flexibility)
+ // 3. Minimum meaningful power (for fine adjustments)
+
+ granularity := 60
+
+ // Charge options - use finer granularity for better optimization
+ for i := granularity; i > 0; i-- {
+ charge := float64(i) * mpc.Config.BatteryMaxCharge / float64(granularity)
if mpc.canCharge(currentSOC, charge) {
batteryActions = append(batteryActions, struct {
charge float64
@@ -182,9 +191,9 @@ func (mpc *MPCController) generateFeasibleDecisions(currentSOC float64, slot Tim
}
}
- // Add discharge options (more granular for better arbitrage opportunities)
- for i := 1; i <= 10; i++ {
- discharge := float64(i) * mpc.Config.BatteryMaxDischarge / 10.0
+ // Discharge options - use finer granularity for better optimization
+ for i := granularity; i > 0; i-- {
+ discharge := float64(i) * mpc.Config.BatteryMaxDischarge / float64(granularity)
if mpc.canDischarge(currentSOC, discharge) {
batteryActions = append(batteryActions, struct {
charge float64
@@ -229,44 +238,30 @@ func (mpc *MPCController) generateFeasibleDecisions(currentSOC float64, slot Tim
}
// calculateProfit computes the profit for a decision
-// This accounts for:
-// 1. Charging battery when prices are low (cost reflected in GridImport)
-// 2. Discharging battery to support load when prices are high (savings reflected in reduced GridImport)
-// 3. Exporting excess power when export prices are favorable
+// The power balance equation ensures: Solar + GridImport + BatteryDischarge*eff = Load + GridExport + BatteryCharge/eff
+// Therefore, GridImport and GridExport already reflect the effect of battery operations.
+// Profit is simply: revenue from exports - cost of imports - degradation cost
func (mpc *MPCController) calculateProfit(dec ControlDecision, slot TimeSlot) float64 {
// Revenue from exporting to grid
revenue := dec.GridExport * slot.ExportPrice
- // Cost of importing from grid (includes charging battery from grid when prices are low)
+ // Cost of importing from grid
importCost := dec.GridImport * slot.ImportPrice
- // Calculate the value of battery operations for arbitrage
- // When we discharge battery to support load, we avoid importing at current price
- // When we charge battery from grid, we pay current price but can use it later at higher prices
-
- // Actual energy delivered from battery (accounting for efficiency)
- batteryDischargeEnergy := dec.BatteryDischarge // kWh delivered
-
- // The discharge value: we avoid importing power at the current price
- // This is beneficial when import price is high
- dischargeValue := batteryDischargeEnergy * mpc.Config.BatteryEfficiency * slot.ImportPrice
-
- // The charge cost: we store energy for future use
- // This is beneficial when import price is low (to use when prices are high)
- // The cost is already included in importCost, but we account for storage losses
- chargeLoss := dec.BatteryCharge * (1 - mpc.Config.BatteryEfficiency) * slot.ImportPrice
-
// Battery degradation cost (wear and tear from cycling)
batteryThroughput := dec.BatteryCharge + dec.BatteryDischarge
degradationCost := batteryThroughput * mpc.Config.BatteryDegradationCost
- // Net profit calculation:
- // + Revenue from exports
- // - Cost of imports (includes grid charging of battery)
- // + Value from discharging battery (avoids expensive imports)
- // - Cost of charging losses
- // - Battery degradation
- profit := revenue - importCost + dischargeValue - chargeLoss - degradationCost
+ // Net profit:
+ // + Revenue from exports (GridExport already accounts for battery discharge to grid)
+ // - Cost of imports (GridImport already accounts for battery charging and reduced imports from discharge)
+ // - Battery degradation (wear and tear cost)
+ //
+ // This correctly incentivizes arbitrage:
+ // - Charging at low import prices reduces profit by importCost
+ // - Discharging at high export prices increases profit by revenue
+ // - The DP optimizer will naturally prefer charge-low/discharge-high strategies
+ profit := revenue - importCost - degradationCost
return profit
}
diff --git a/mpc/mpc_test.go b/mpc/mpc_test.go
new file mode 100644
index 0000000..6c4c2fb
--- /dev/null
+++ b/mpc/mpc_test.go
@@ -0,0 +1,802 @@
+package mpc
+
+import (
+ "fmt"
+ "math"
+ "testing"
+)
+
+func TestCalculateProfit(t *testing.T) {
+ tests := []struct {
+ name string
+ config SystemConfig
+ decision ControlDecision
+ slot TimeSlot
+ expectedProfit float64
+ description string
+ }{
+ {
+ name: "Battery discharge to support load only - no export",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 3.0, // 3 kW discharge
+ GridImport: 1.3, // Need to import some
+ GridExport: 0,
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.30, // $0.30/kWh
+ ExportPrice: 0.10, // $0.10/kWh
+ SolarForecast: 1.0, // 1 kW solar
+ LoadForecast: 5.0, // 5 kW load
+ },
+ // Revenue: 0 (no export)
+ // Import cost: 1.3 * 0.30 = 0.39
+ // Degradation: 3 * 0.01 = 0.03
+ // Profit: 0 - 0.39 - 0.03 = -0.42
+ expectedProfit: -0.42,
+ description: "Battery helps meet load, avoiding some imports",
+ },
+ {
+ name: "Battery discharge with export - no double counting",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 5.0, // 5 kW discharge
+ GridImport: 0,
+ GridExport: 3.5, // Export excess
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.30, // $0.30/kWh
+ ExportPrice: 0.10, // $0.10/kWh
+ SolarForecast: 2.0, // 2 kW solar
+ LoadForecast: 3.0, // 3 kW load
+ },
+ // Revenue: 3.5 * 0.10 = 0.35
+ // Import cost: 0
+ // Degradation: 5 * 0.01 = 0.05
+ // Profit: 0.35 - 0 - 0.05 = 0.30
+ expectedProfit: 0.30,
+ description: "Battery discharge split between load and export - no double counting",
+ },
+ {
+ name: "Battery charging from grid",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 4.0, // 4 kW charging
+ BatteryDischarge: 0,
+ GridImport: 6.444, // Load + charge losses
+ GridExport: 0,
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.10, // $0.10/kWh (cheap - good time to charge)
+ ExportPrice: 0.05, // $0.05/kWh
+ SolarForecast: 0.5, // 0.5 kW solar
+ LoadForecast: 2.0, // 2 kW load
+ },
+ // Revenue: 0
+ // Import cost: 6.444 * 0.10 = 0.6444
+ // Degradation: 4 * 0.01 = 0.04
+ // Profit: 0 - 0.6444 - 0.04 = -0.6844
+ expectedProfit: -0.6844,
+ description: "Charging battery incurs cost (future arbitrage opportunity)",
+ },
+ {
+ name: "Solar export only - no battery",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 0,
+ GridImport: 0,
+ GridExport: 5.0, // Export excess solar
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.25,
+ ExportPrice: 0.08,
+ SolarForecast: 8.0, // 8 kW solar
+ LoadForecast: 3.0, // 3 kW load
+ },
+ // Revenue: 5.0 * 0.08 = 0.40
+ // Import cost: 0
+ // Discharge value: 0
+ // Charge loss: 0
+ // Degradation: 0
+ // Profit: 0.40 - 0 + 0 - 0 - 0 = 0.40
+ expectedProfit: 0.40,
+ description: "Pure solar export scenario",
+ },
+ {
+ name: "Grid import only - no solar, no battery",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 0,
+ GridImport: 4.0,
+ GridExport: 0,
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.35,
+ ExportPrice: 0.10,
+ SolarForecast: 0, // No solar
+ LoadForecast: 4.0, // 4 kW load
+ },
+ // Revenue: 0
+ // Import cost: 4.0 * 0.35 = 1.40
+ // Discharge value: 0
+ // Charge loss: 0
+ // Degradation: 0
+ // Profit: 0 - 1.40 + 0 - 0 - 0 = -1.40
+ expectedProfit: -1.40,
+ description: "Pure grid import scenario - highest cost",
+ },
+ {
+ name: "Solar meets load exactly - no grid interaction",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 0,
+ GridImport: 0,
+ GridExport: 0,
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.30,
+ ExportPrice: 0.10,
+ SolarForecast: 5.0, // 5 kW solar
+ LoadForecast: 5.0, // 5 kW load
+ },
+ // Revenue: 0
+ // Import cost: 0
+ // Discharge value: 0
+ // Charge loss: 0
+ // Degradation: 0
+ // Profit: 0
+ expectedProfit: 0.0,
+ description: "Perfect balance - no cost, no revenue",
+ },
+ {
+ name: "High efficiency battery - 95%",
+ config: SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.95, // Higher efficiency
+ BatteryDegradationCost: 0.005, // Lower degradation
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ },
+ decision: ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 4.0,
+ GridImport: 0,
+ GridExport: 2.8,
+ },
+ slot: TimeSlot{
+ ImportPrice: 0.40,
+ ExportPrice: 0.12,
+ SolarForecast: 3.0,
+ LoadForecast: 3.0,
+ },
+ // Revenue: 2.8 * 0.12 = 0.336
+ // Import cost: 0
+ // Battery effective discharge: 4 * 0.95 = 3.8 kW
+ // Load deficit from solar: 3 - 3 = 0 kW
+ // Battery to load: min(3.8, 0) = 0 kW (all goes to export)
+ // Discharge value: 0 * 0.40 = 0
+ // Charge loss: 0
+ // Degradation: 4 * 0.005 = 0.02
+ // Profit: 0.336 - 0 + 0 - 0 - 0.02 = 0.316
+ expectedProfit: 0.316,
+ description: "All battery discharge goes to export when load is met by solar",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mpc := &MPCController{
+ Config: tt.config,
+ }
+
+ profit := mpc.calculateProfit(tt.decision, tt.slot)
+
+ // Use a small epsilon for floating point comparison
+ epsilon := 0.0001
+ if math.Abs(profit-tt.expectedProfit) > epsilon {
+ t.Errorf("%s\nExpected profit: %.4f, got: %.4f\nDifference: %.4f",
+ tt.description, tt.expectedProfit, profit, profit-tt.expectedProfit)
+
+ // Debug information
+ t.Logf("Decision: Charge=%.2f, Discharge=%.2f, Import=%.2f, Export=%.2f",
+ tt.decision.BatteryCharge, tt.decision.BatteryDischarge,
+ tt.decision.GridImport, tt.decision.GridExport)
+ t.Logf("Slot: Solar=%.2f, Load=%.2f, ImportPrice=%.2f, ExportPrice=%.2f",
+ tt.slot.SolarForecast, tt.slot.LoadForecast,
+ tt.slot.ImportPrice, tt.slot.ExportPrice)
+ }
+ })
+ }
+}
+
+func TestCalculateProfitNoDegradation(t *testing.T) {
+ // Test with zero degradation cost to verify other calculations
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.0, // No degradation
+ }
+
+ mpc := &MPCController{
+ Config: config,
+ }
+
+ decision := ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 2.0,
+ GridImport: 0.2,
+ GridExport: 0,
+ }
+
+ slot := TimeSlot{
+ ImportPrice: 0.50,
+ ExportPrice: 0.15,
+ SolarForecast: 1.0,
+ LoadForecast: 3.0,
+ }
+
+ // Revenue: 0
+ // Import cost: 0.2 * 0.50 = 0.10
+ // Degradation: 0
+ // Profit: 0 - 0.10 - 0 = -0.10
+ expectedProfit := -0.10
+
+ profit := mpc.calculateProfit(decision, slot)
+
+ epsilon := 0.0001
+ if math.Abs(profit-expectedProfit) > epsilon {
+ t.Errorf("Expected profit: %.4f, got: %.4f", expectedProfit, profit)
+ }
+}
+
+func TestCalculateProfitArbitrage(t *testing.T) {
+ // Test arbitrage scenario: charge when cheap, discharge when expensive
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ }
+
+ mpc := &MPCController{
+ Config: config,
+ }
+
+ // Scenario 1: Charge during cheap prices
+ chargeDecision := ControlDecision{
+ BatteryCharge: 5.0,
+ BatteryDischarge: 0,
+ GridImport: 7.556, // Load + charge/efficiency
+ GridExport: 0,
+ }
+
+ cheapSlot := TimeSlot{
+ ImportPrice: 0.05, // Very cheap
+ ExportPrice: 0.02,
+ SolarForecast: 0,
+ LoadForecast: 2.0,
+ }
+
+ chargeProfit := mpc.calculateProfit(chargeDecision, cheapSlot)
+
+ // Should be negative (cost) but small due to cheap price
+ if chargeProfit >= 0 {
+ t.Errorf("Expected negative profit when charging, got: %.4f", chargeProfit)
+ }
+
+ // Scenario 2: Discharge during expensive prices with export
+ dischargeDecision := ControlDecision{
+ BatteryCharge: 0,
+ BatteryDischarge: 4.0,
+ GridImport: 0,
+ GridExport: 0.6, // 4*0.9 - 3.0 = 0.6 exported
+ }
+
+ expensiveSlot := TimeSlot{
+ ImportPrice: 0.50, // Very expensive
+ ExportPrice: 0.15,
+ SolarForecast: 0,
+ LoadForecast: 3.0,
+ }
+
+ dischargeProfit := mpc.calculateProfit(dischargeDecision, expensiveSlot)
+
+ // With new calculation: revenue from export minus degradation
+ // Revenue: 0.6 * 0.15 = 0.09, Degradation: 4 * 0.01 = 0.04
+ // Profit: 0.09 - 0 - 0.04 = 0.05
+ // This is positive, demonstrating that discharging at high prices can be profitable
+ if dischargeProfit <= 0 {
+ t.Logf("Charge cost: %.4f, Discharge profit: %.4f", chargeProfit, dischargeProfit)
+ t.Logf("Note: With corrected profit calculation, arbitrage requires actual grid export at higher prices")
+ }
+}
+
+func TestOptimize(t *testing.T) {
+ t.Log("=== Analyzing Arbitrage Opportunity ===")
+
+ // Hourly average prices (EUR/MWh) calculated from:
+ // Energy_Prices_202601032300-202601042300.xml (hours 0-11)
+ // Energy_Prices_202601042300-202601052300.xml (hours 12-35)
+ hourlyPrices := []float64{
+ 97.365, // hour 0
+ 112.4925, // hour 1
+ 126.4425, // hour 2
+ 162.355, // hour 3
+ 139.1275, // hour 4
+ 143.575, // hour 5
+ 125.4875, // hour 6
+ 119.715, // hour 7
+ 112.45, // hour 8
+ 127.1225, // hour 9
+ 110.065, // hour 10
+ 89.185, // hour 11
+ 130.44, // hour 12
+ 107.56, // hour 13
+ 96.9075, // hour 14
+ 103.66, // hour 15
+ 106.5375, // hour 16
+ 126.105, // hour 17
+ 123.465, // hour 18
+ 129.3125, // hour 19
+ 151.1725, // hour 20
+ 152.145, // hour 21
+ 145.9825, // hour 22
+ 139.98, // hour 23
+ 137.6925, // hour 24
+ 142.795, // hour 25
+ 159.14, // hour 26
+ 200.61, // hour 27
+ 222.925, // hour 28
+ 228.3575, // hour 29
+ 218.92, // hour 30
+ 182.785, // hour 31
+ 147.03, // hour 32
+ 129.35, // hour 33
+ 123.8475, // hour 34
+ 112.3, // hour 35
+ }
+
+ // Create forecast with zero solar and 0.38 kW load for all timeslots
+ forecast := make([]TimeSlot, len(hourlyPrices))
+ for i := range len(hourlyPrices) {
+ forecast[i] = TimeSlot{
+ Hour: i,
+ Timestamp: int64(1704326400 + i*3600), // Starting from 2024-01-04 00:00:00 UTC
+ ImportPrice: hourlyPrices[i]/1000.0 + 0.04 + 0.0085, // Convert EUR/MWh to EUR/kWh
+ ExportPrice: hourlyPrices[i]/1000.0 - 0.017, // Convert EUR/MWh to EUR/kWh
+ SolarForecast: 0.0, // Zero solar as specified
+ LoadForecast: 0.38, // 0.38 kW load as specified
+ }
+ }
+
+ // forecast[15].SolarForecast = 0.5
+ // forecast[16].SolarForecast = 2.3
+ // forecast[17].SolarForecast = 3.0
+ // forecast[18].SolarForecast = 1.2
+ // forecast[19].SolarForecast = 1.2
+ // forecast[20].SolarForecast = 0.5
+
+ // System configuration
+ config := SystemConfig{
+ BatteryCapacity: 24.0, // 24 kWh battery
+ BatteryMaxCharge: 12.0, // 12 kW max charge rate
+ BatteryMaxDischarge: 12.0, // 12 kW max discharge rate
+ BatteryMinSOC: 0.0, // 0% minimum SOC
+ BatteryMaxSOC: 1.0, // 100% maximum SOC
+ BatteryEfficiency: 0.9, // 90% round-trip efficiency
+ BatteryDegradationCost: 0.0, // €0.00/kWh degradation cost
+ MaxGridImport: 30.0, // 30 kW max grid import
+ MaxGridExport: 30.0, // 30 kW max grid export
+ }
+
+ // Create MPC controller with 25% initial SOC
+ mpc := NewMPCController(config, len(hourlyPrices), 0.25)
+
+ // Run optimization
+ decisions := mpc.Optimize(forecast)
+
+ // Validate results
+ if decisions == nil {
+ t.Fatal("Optimize returned nil")
+ }
+
+ if len(decisions) != len(hourlyPrices) {
+ t.Fatalf("Expected %d decisions, got %d", len(hourlyPrices), len(decisions))
+ }
+
+ // Verify all decisions have valid data
+ for i, dec := range decisions {
+ t.Logf("Hour %2d: SOC=%.3f, Charge=%.3f, Discharge=%.3f, Import=%.3f, Export=%.3f, Profit=€%.4f, Price=€%.5f/kWh",
+ i, dec.BatterySOC, dec.BatteryCharge, dec.BatteryDischarge,
+ dec.GridImport, dec.GridExport, dec.Profit, dec.ImportPrice)
+
+ // Check SOC bounds
+ if dec.BatterySOC < config.BatteryMinSOC-0.001 || dec.BatterySOC > config.BatteryMaxSOC+0.001 {
+ t.Errorf("Hour %d: SOC %.3f out of bounds [%.3f, %.3f]",
+ i, dec.BatterySOC, config.BatteryMinSOC, config.BatteryMaxSOC)
+ }
+
+ // Check charge/discharge bounds
+ if dec.BatteryCharge > config.BatteryMaxCharge+0.001 {
+ t.Errorf("Hour %d: Charge %.3f exceeds max %.3f", i, dec.BatteryCharge, config.BatteryMaxCharge)
+ }
+ if dec.BatteryDischarge > config.BatteryMaxDischarge+0.001 {
+ t.Errorf("Hour %d: Discharge %.3f exceeds max %.3f", i, dec.BatteryDischarge, config.BatteryMaxDischarge)
+ }
+
+ // Check grid bounds
+ if dec.GridImport > config.MaxGridImport+0.001 {
+ t.Errorf("Hour %d: Grid import %.3f exceeds max %.3f", i, dec.GridImport, config.MaxGridImport)
+ }
+ if dec.GridExport > config.MaxGridExport+0.001 {
+ t.Errorf("Hour %d: Grid export %.3f exceeds max %.3f", i, dec.GridExport, config.MaxGridExport)
+ }
+
+ // Check mutual exclusivity of charge/discharge
+ if dec.BatteryCharge > 0.001 && dec.BatteryDischarge > 0.001 {
+ t.Errorf("Hour %d: Both charge and discharge are non-zero", i)
+ }
+
+ // Check mutual exclusivity of grid import/export
+ if dec.GridImport > 0.001 && dec.GridExport > 0.001 {
+ t.Errorf("Hour %d: Both grid import and export are non-zero", i)
+ }
+
+ // Verify forecast data is preserved
+ if dec.ImportPrice != forecast[i].ImportPrice {
+ t.Errorf("Hour %d: Import price mismatch", i)
+ }
+ if dec.LoadForecast != forecast[i].LoadForecast {
+ t.Errorf("Hour %d: Load forecast mismatch", i)
+ }
+ if dec.SolarForecast != forecast[i].SolarForecast {
+ t.Errorf("Hour %d: Solar forecast mismatch", i)
+ }
+ }
+
+ // Calculate total profit/cost
+ totalProfit := 0.0
+ for _, dec := range decisions {
+ totalProfit += dec.Profit
+ }
+ t.Logf("Total profit over 36 hours: €%.4f", totalProfit)
+
+ // Detailed arbitrage analysis
+ t.Log("\n=== Arbitrage Analysis ===")
+
+ // Find hour 4 (lowest price) details
+ hour4 := decisions[4]
+ importPriceHour4 := forecast[4].ImportPrice
+ exportPriceHour4 := forecast[4].ExportPrice
+
+ // Find hour 22 (highest price) details
+ hour22 := decisions[22]
+ importPriceHour22 := forecast[22].ImportPrice
+ exportPriceHour22 := forecast[22].ExportPrice
+
+ t.Logf("\nHour 4 (LOWEST PRICE):")
+ t.Logf(" Import Price: €%.5f/kWh", importPriceHour4)
+ t.Logf(" Export Price: €%.5f/kWh", exportPriceHour4)
+ t.Logf(" Charged: %.3f kW (added %.3f kWh to battery)", hour4.BatteryCharge, hour4.BatteryCharge*0.9)
+ t.Logf(" Cost: €%.4f (import + degradation)", hour4.BatteryCharge*importPriceHour4+hour4.BatteryCharge*config.BatteryDegradationCost)
+
+ t.Logf("\nHour 22 (HIGHEST PRICE):")
+ t.Logf(" Import Price: €%.5f/kWh", importPriceHour22)
+ t.Logf(" Export Price: €%.5f/kWh", exportPriceHour22)
+ t.Logf(" Discharged: %.3f kW (%.3f kWh from battery)", hour22.BatteryDischarge, hour22.BatteryDischarge)
+ t.Logf(" Exported: %.3f kW", hour22.GridExport)
+ t.Logf(" Revenue: €%.4f (export - degradation)", hour22.GridExport*exportPriceHour22-hour22.BatteryDischarge*config.BatteryDegradationCost)
+
+ // Calculate theoretical max arbitrage if charged more at hour 4
+ maxAdditionalCharge := config.BatteryMaxCharge - hour4.BatteryCharge
+ socHeadroom := (config.BatteryMaxSOC - hour4.BatterySOC) * config.BatteryCapacity
+ actualMaxCharge := math.Min(maxAdditionalCharge, socHeadroom/config.BatteryEfficiency)
+
+ if actualMaxCharge > 0.1 {
+ t.Logf("\nTheoretical Additional Arbitrage Opportunity:")
+ t.Logf(" Could charge additional: %.3f kW at hour 4", actualMaxCharge)
+ t.Logf(" Energy stored after efficiency: %.3f kWh", actualMaxCharge*config.BatteryEfficiency)
+ t.Logf(" Cost to charge: €%.4f", actualMaxCharge*importPriceHour4+actualMaxCharge*config.BatteryDegradationCost)
+ t.Logf(" Revenue if exported at hour 22: €%.4f", actualMaxCharge*config.BatteryEfficiency*exportPriceHour22-actualMaxCharge*config.BatteryDegradationCost)
+ t.Logf(" Net profit from additional arbitrage: €%.4f",
+ actualMaxCharge*config.BatteryEfficiency*exportPriceHour22-
+ actualMaxCharge*importPriceHour4-
+ 2*actualMaxCharge*config.BatteryDegradationCost)
+ }
+
+ // Analyze where battery energy went
+ t.Logf("\nBattery Usage Timeline:")
+ chargedTotal := 0.0
+ dischargedTotal := 0.0
+ for i, dec := range decisions {
+ if dec.BatteryCharge > 0.1 {
+ chargedTotal += dec.BatteryCharge
+ t.Logf(" Hour %2d: CHARGED %.3f kW at price €%.5f/kWh (SOC: %.3f)",
+ i, dec.BatteryCharge, dec.ImportPrice, dec.BatterySOC)
+ }
+ if dec.BatteryDischarge > 0.1 {
+ dischargedTotal += dec.BatteryDischarge
+ exported := ""
+ if dec.GridExport > 0.001 {
+ exported = fmt.Sprintf(" → EXPORTED %.3f kW", dec.GridExport)
+ }
+ t.Logf(" Hour %2d: DISCHARGED %.3f kW at price €%.5f/kWh (SOC: %.3f)%s",
+ i, dec.BatteryDischarge, dec.ExportPrice, dec.BatterySOC, exported)
+ }
+ }
+ t.Logf("\nTotal charged: %.3f kW, Total discharged: %.3f kW", chargedTotal, dischargedTotal)
+ t.Logf("Degradation cost: €%.4f (%.3f kW throughput × €%.2f/kWh)",
+ (chargedTotal+dischargedTotal)*config.BatteryDegradationCost,
+ chargedTotal+dischargedTotal,
+ config.BatteryDegradationCost)
+
+ // Verify arbitrage behavior: should charge during low-price periods and discharge during high-price periods
+ t.Log("\n=== Optimization Validation ===")
+ // Find hours with lowest and highest prices
+ minPriceHour := 0
+ maxPriceHour := 0
+ minPrice := hourlyPrices[0]
+ maxPrice := hourlyPrices[0]
+
+ for i := 1; i < len(hourlyPrices); i++ {
+ if hourlyPrices[i] < minPrice {
+ minPrice = hourlyPrices[i]
+ minPriceHour = i
+ }
+ if hourlyPrices[i] > maxPrice {
+ maxPrice = hourlyPrices[i]
+ maxPriceHour = i
+ }
+ }
+
+ t.Logf("Lowest price: €%.5f/kWh at hour %d", minPrice, minPriceHour)
+ t.Logf("Highest price: €%.5f/kWh at hour %d", maxPrice, maxPriceHour)
+
+ // Check that battery tends to charge during low prices
+ // (Not strict since it depends on SOC constraints and overall optimization)
+ lowPriceCharging := 0
+ for i := range len(hourlyPrices) {
+ if hourlyPrices[i] < (minPrice+maxPrice)/2 && decisions[i].BatteryCharge > 0.1 {
+ lowPriceCharging++
+ }
+ }
+
+ highPriceDischarging := 0
+ for i := range len(hourlyPrices) {
+ if hourlyPrices[i] > (minPrice+maxPrice)/2 && decisions[i].BatteryDischarge > 0.1 {
+ highPriceDischarging++
+ }
+ }
+
+ t.Logf("Hours with charging during low prices: %d", lowPriceCharging)
+ t.Logf("Hours with discharging during high prices: %d", highPriceDischarging)
+
+ // Verify power balance for each hour
+ for i, dec := range decisions {
+ // Power balance: Solar + GridImport + BatteryDischarge*eff = Load + GridExport + BatteryCharge/eff
+ supply := forecast[i].SolarForecast + dec.GridImport + dec.BatteryDischarge*config.BatteryEfficiency
+ demand := forecast[i].LoadForecast + dec.GridExport + dec.BatteryCharge/config.BatteryEfficiency
+
+ if math.Abs(supply-demand) > 0.01 {
+ t.Errorf("Hour %d: Power balance violation. Supply=%.3f, Demand=%.3f, Diff=%.3f",
+ i, supply, demand, supply-demand)
+ }
+ }
+
+ // Final SOC should be different from initial (battery was used)
+ finalSOC := decisions[len(hourlyPrices)-1].BatterySOC
+ t.Logf("Initial SOC: %.3f, Final SOC: %.3f", 0.5, finalSOC)
+}
+
+func TestOptimizeEmptyForecast(t *testing.T) {
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ }
+
+ mpc := NewMPCController(config, 24, 0.5)
+ decisions := mpc.Optimize([]TimeSlot{})
+
+ if decisions != nil {
+ t.Error("Expected nil for empty forecast")
+ }
+}
+
+func TestOptimizeShortHorizon(t *testing.T) {
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ }
+
+ // Test with just 2 hours
+ forecast := []TimeSlot{
+ {
+ Hour: 0,
+ Timestamp: 1704326400,
+ ImportPrice: 0.05, // Cheap - should charge
+ ExportPrice: 0.02,
+ SolarForecast: 0.0,
+ LoadForecast: 0.5,
+ },
+ {
+ Hour: 1,
+ Timestamp: 1704330000,
+ ImportPrice: 0.20, // Expensive - should discharge
+ ExportPrice: 0.08,
+ SolarForecast: 0.0,
+ LoadForecast: 0.5,
+ },
+ }
+
+ mpc := NewMPCController(config, 2, 0.5)
+ decisions := mpc.Optimize(forecast)
+
+ if len(decisions) != 2 {
+ t.Fatalf("Expected 2 decisions, got %d", len(decisions))
+ }
+
+ // During cheap hour, battery should tend to charge (or at least not discharge heavily)
+ // During expensive hour, battery should tend to discharge
+ t.Logf("Hour 0 (cheap): Charge=%.3f, Discharge=%.3f", decisions[0].BatteryCharge, decisions[0].BatteryDischarge)
+ t.Logf("Hour 1 (expensive): Charge=%.3f, Discharge=%.3f", decisions[1].BatteryCharge, decisions[1].BatteryDischarge)
+}
+
+func TestOptimizeHighLoad(t *testing.T) {
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ }
+
+ // Test with high load that exceeds battery discharge capacity
+ forecast := []TimeSlot{
+ {
+ Hour: 0,
+ Timestamp: 1704326400,
+ ImportPrice: 0.30,
+ ExportPrice: 0.10,
+ SolarForecast: 0.0,
+ LoadForecast: 8.0, // High load - 8 kW
+ },
+ }
+
+ mpc := NewMPCController(config, 1, 0.9) // Start at high SOC
+ decisions := mpc.Optimize(forecast)
+
+ if len(decisions) != 1 {
+ t.Fatalf("Expected 1 decision, got %d", len(decisions))
+ }
+
+ // With 8 kW load and max 5 kW discharge, should need grid import
+ if decisions[0].GridImport <= 0 {
+ t.Logf("Warning: Expected grid import with high load, got %.3f", decisions[0].GridImport)
+ }
+
+ t.Logf("High load scenario: Discharge=%.3f, Import=%.3f", decisions[0].BatteryDischarge, decisions[0].GridImport)
+}
+
+func TestOptimizeHighSolar(t *testing.T) {
+ config := SystemConfig{
+ BatteryCapacity: 10.0,
+ BatteryMaxCharge: 5.0,
+ BatteryMaxDischarge: 5.0,
+ BatteryMinSOC: 0.1,
+ BatteryMaxSOC: 0.9,
+ BatteryEfficiency: 0.9,
+ BatteryDegradationCost: 0.01,
+ MaxGridImport: 10.0,
+ MaxGridExport: 10.0,
+ }
+
+ // Test with high solar generation
+ forecast := []TimeSlot{
+ {
+ Hour: 0,
+ Timestamp: 1704326400,
+ ImportPrice: 0.10,
+ ExportPrice: 0.05,
+ SolarForecast: 8.0, // High solar - 8 kW
+ LoadForecast: 2.0,
+ },
+ }
+
+ mpc := NewMPCController(config, 1, 0.1) // Start at low SOC
+ decisions := mpc.Optimize(forecast)
+
+ if len(decisions) != 1 {
+ t.Fatalf("Expected 1 decision, got %d", len(decisions))
+ }
+
+ // With 8 kW solar and 2 kW load, should have excess to charge or export
+ excessUsed := decisions[0].BatteryCharge + decisions[0].GridExport
+ if excessUsed <= 0 {
+ t.Error("Expected battery charging or grid export with excess solar")
+ }
+
+ t.Logf("High solar scenario: Charge=%.3f, Export=%.3f", decisions[0].BatteryCharge, decisions[0].GridExport)
+}
diff --git a/scheduler/config.go b/scheduler/config.go
index e928077..a2accc4 100644
--- a/scheduler/config.go
+++ b/scheduler/config.go
@@ -65,16 +65,17 @@ type Config struct {
UserAgent string `json:"user_agent"` // User agent for weather API client
// Battery/Inverter system configuration (MPC)
- BatteryCapacity float64 `json:"battery_capacity"` // kWh
- BatteryMaxCharge float64 `json:"battery_max_charge"` // kW
- BatteryMaxDischarge float64 `json:"battery_max_discharge"` // kW
- BatteryMinSOC float64 `json:"battery_min_soc"` // percentage (0-1)
- BatteryMaxSOC float64 `json:"battery_max_soc"` // percentage (0-1)
- BatteryEfficiency float64 `json:"battery_efficiency"` // round-trip efficiency (0-1)
- BatteryDegradationCost float64 `json:"battery_degradation_cost"` // $/kWh cycled
- MaxGridImport float64 `json:"max_grid_import"` // kW
- MaxGridExport float64 `json:"max_grid_export"` // kW
- MaxSolarPower float64 `json:"max_solar_power"` // kW - peak solar power capacity
+ BatteryCapacity float64 `json:"battery_capacity"` // kWh
+ BatteryMaxCharge float64 `json:"battery_max_charge"` // kW
+ BatteryMaxDischarge float64 `json:"battery_max_discharge"` // kW
+ BatteryMinSOC float64 `json:"battery_min_soc"` // percentage (0-1)
+ BatteryMaxSOC float64 `json:"battery_max_soc"` // percentage (0-1)
+ BatteryEfficiency float64 `json:"battery_efficiency"` // round-trip efficiency (0-1)
+ BatteryDegradationCost float64 `json:"battery_degradation_cost"` // $/kWh cycled
+ MaxGridImport float64 `json:"max_grid_import"` // kW
+ MaxGridExport float64 `json:"max_grid_export"` // kW
+ MaxSolarPower float64 `json:"max_solar_power"` // kW - peak solar power capacity
+ MPCExecutionInterval time.Duration `json:"mpc_execution_interval"` // How often to re-execute current MPC decision
// Price adjustments
ImportPriceOperatorFee float64 `json:"import_price_operator_fee"` // EUR/MWh - Operator fee for import
@@ -90,6 +91,7 @@ func DefaultConfig() *Config {
CheckPriceInterval: 15 * time.Minute,
MinersStateCheckInterval: 1 * time.Minute,
MinerDiscoveryInterval: 10 * time.Minute,
+ MPCExecutionInterval: 1 * time.Minute,
DryRun: false,
APITimeout: 30 * time.Second,
LogLevel: "info",
@@ -112,7 +114,7 @@ func DefaultConfig() *Config {
BatteryMinSOC: 0.0, // 0%
BatteryMaxSOC: 1.0, // 100%
BatteryEfficiency: 0.92, // 92% round-trip
- BatteryDegradationCost: 0.05, // $0.05 per kWh cycled
+ BatteryDegradationCost: 0.0, // $0.00 per kWh cycled
MaxGridImport: 30.0, // 30 kW
MaxGridExport: 30.0, // 30 kW
MaxSolarPower: 30.0, // 30 kW peak solar power
@@ -360,6 +362,7 @@ func (c *Config) MarshalJSON() ([]byte, error) {
CheckInterval string `json:"check_price_interval"`
MinersStateCheckInterval string `json:"miners_state_check_interval"`
MinerDiscoveryInterval string `json:"miner_discovery_interval"`
+ MPCExecutionInterval string `json:"mpc_execution_interval"`
APITimeout string `json:"api_timeout"`
MinerTimeout string `json:"miner_timeout"`
PVPollInterval string `json:"pv_poll_interval"`
@@ -370,6 +373,7 @@ func (c *Config) MarshalJSON() ([]byte, error) {
CheckInterval: c.CheckPriceInterval.String(),
MinersStateCheckInterval: c.MinersStateCheckInterval.String(),
MinerDiscoveryInterval: c.MinerDiscoveryInterval.String(),
+ MPCExecutionInterval: c.MPCExecutionInterval.String(),
APITimeout: c.APITimeout.String(),
MinerTimeout: c.MinerTimeout.String(),
PVPollInterval: c.PVPollInterval.String(),
@@ -386,6 +390,7 @@ func (c *Config) UnmarshalJSON(data []byte) error {
CheckPriceInterval string `json:"check_price_interval"`
MinersStateCheckInterval string `json:"miners_state_check_interval"`
MinerDiscoveryInterval string `json:"miner_discovery_interval"`
+ MPCExecutionInterval string `json:"mpc_execution_interval"`
APITimeout string `json:"api_timeout"`
MinerTimeout string `json:"miner_timeout"`
UrlFormat string `json:"url_format"`
@@ -425,6 +430,12 @@ func (c *Config) UnmarshalJSON(data []byte) error {
}
}
+ if aux.MPCExecutionInterval != "" {
+ if c.MPCExecutionInterval, err = time.ParseDuration(aux.MPCExecutionInterval); err != nil {
+ return fmt.Errorf("invalid mpc_execution_interval: %w", err)
+ }
+ }
+
if aux.APITimeout != "" {
if c.APITimeout, err = time.ParseDuration(aux.APITimeout); err != nil {
return fmt.Errorf("invalid api_timeout: %w", err)
diff --git a/scheduler/miners.go b/scheduler/miners.go
index 6e405b5..90cb618 100644
--- a/scheduler/miners.go
+++ b/scheduler/miners.go
@@ -41,8 +41,8 @@ func (s *MinerScheduler) discoverMiners(ctx context.Context) error {
return nil
}
-// runMinerDiscovery runs the miner discovery process as a scheduled task
-func (s *MinerScheduler) runMinerDiscovery(ctx context.Context) {
+// RunMinerDiscovery runs the miner discovery process as a scheduled task
+func (s *MinerScheduler) RunMinerDiscovery(ctx context.Context) {
s.logger.Printf("Starting miner discovery task at %s", time.Now().Format(time.RFC3339))
if err := s.discoverMiners(ctx); err != nil {
diff --git a/scheduler/mpc.go b/scheduler/mpc.go
index ec81e14..1ec6295 100644
--- a/scheduler/mpc.go
+++ b/scheduler/mpc.go
@@ -12,8 +12,8 @@ import (
"github.com/sixdouglas/suncalc"
)
-// runMPCOptimize executes the MPC optimization task
-func (s *MinerScheduler) runMPCOptimize(ctx context.Context) {
+// RunMPCOptimize executes the MPC optimization task
+func (s *MinerScheduler) RunMPCOptimize(ctx context.Context) {
s.logger.Printf("Starting MPC optimization task at %s", time.Now().Format(time.RFC3339))
config := s.GetConfig()
@@ -75,6 +75,7 @@ func (s *MinerScheduler) runMPCOptimize(ctx context.Context) {
// Step 5: Save optimization results
s.mu.Lock()
s.mpcDecisions = decisions
+ s.lastMPCExecutionError = nil // Clear any previous execution error
s.mu.Unlock()
// Log summary
@@ -83,11 +84,18 @@ func (s *MinerScheduler) runMPCOptimize(ctx context.Context) {
for _, dec := range decisions {
totalProfit += dec.Profit
}
- s.logger.Printf("Total expected profit over %d hours: €%.2f", len(decisions), totalProfit)
+ s.logger.Printf("Total expected profit over %d hours: %.2f EUR", len(decisions), totalProfit)
// Step 6: Execute the first control decision
- if err := s.executeMPCDecision(&decisions[0], true); err != nil {
- s.logger.Printf("Error executing MPC decision: %v", err)
+ err = s.executeMPCDecision(&decisions[0], config.DryRun)
+
+ // Record execution status
+ s.mu.Lock()
+ s.lastMPCExecutionError = err
+ s.mu.Unlock()
+
+ if err != nil {
+ s.logger.Printf("Error executing MPC decision: %v (will retry every minute)", err)
return
}
@@ -184,7 +192,7 @@ type PricePoint struct {
Export float64 // EUR/MWh
}
-// getPriceForecast gets electricity prices for the next 24 hours
+// getPriceForecast gets electricity prices for the next 36 hours
func (s *MinerScheduler) getPriceForecast(ctx context.Context, now time.Time) (map[int]PricePoint, error) {
// Get the market data
@@ -199,9 +207,9 @@ func (s *MinerScheduler) getPriceForecast(ctx context.Context, now time.Time) (m
// Get configuration for price adjustments
config := s.GetConfig()
- // Extract prices for next 24 hours
+ // Extract prices for next 36 hours
forecast := make(map[int]PricePoint)
- for i := range 24 {
+ for i := range 36 {
futureTime := now.Add(time.Duration(i) * time.Hour)
price, found := marketData.LookupAveragePriceInHourByTime(futureTime)
@@ -235,7 +243,7 @@ func (s *MinerScheduler) getSolarForecast(config *Config, now time.Time, weather
solarForecast := make(map[int]float64)
weatherData := make(map[int]WeatherData)
- for i := range 24 {
+ for i := range 36 {
futureTime := now.Add(time.Duration(i) * time.Hour)
solarPower, cloudCoverage, weatherSymbol := s.estimateSolarPowerFromWeather(weatherForecast, futureTime, config.MaxSolarPower, currentPVPower)
solarForecast[i] = solarPower
@@ -431,6 +439,129 @@ func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRu
return nil
}
- s.logger.Printf("Successfully executed MPC decision")
+ config := s.GetConfig()
+
+ // Connect to Plant Modbus server
+ client, err := sigenergy.NewTCPClient(config.PlantModbusAddress, sigenergy.PlantAddress)
+ if err != nil {
+ return fmt.Errorf("failed to connect to Plant Modbus: %w", err)
+ }
+ defer client.Close()
+
+ // Enable Remote EMS control
+ if err := client.EnableRemoteEMS(true); err != nil {
+ return fmt.Errorf("failed to enable remote EMS: %w", err)
+ }
+ s.logger.Printf("Enabled Remote EMS control")
+
+ // Determine control mode based on decision
+ var mode uint16
+
+ if decision.BatteryCharge > 0.01 {
+ // Battery should charge
+ // Mode 4: Command charging (PV first) - charge from PV, then grid if needed
+ mode = 4
+ chargeLimit := decision.BatteryCharge
+ s.logger.Printf("Setting battery to CHARGE mode: %.1f kW", chargeLimit)
+
+ // Set Remote EMS control mode
+ if err := client.SetRemoteEMSMode(mode); err != nil {
+ return fmt.Errorf("failed to set remote EMS mode: %w", err)
+ }
+
+ // Set ESS max charging limit
+ if err := client.SetESSMaxChargingLimit(chargeLimit); err != nil {
+ return fmt.Errorf("failed to set ESS charging limit: %w", err)
+ }
+
+ } else if decision.BatteryDischarge > 0.01 {
+ // Battery should discharge
+ // Mode 6: Command discharging (ESS first) - discharge from battery first
+ mode = 6
+ dischargeLimit := decision.BatteryDischarge
+ s.logger.Printf("Setting battery to DISCHARGE mode: %.1f kW", dischargeLimit)
+
+ // Set Remote EMS control mode
+ if err := client.SetRemoteEMSMode(mode); err != nil {
+ return fmt.Errorf("failed to set remote EMS mode: %w", err)
+ }
+
+ // Set ESS max discharging limit
+ if err := client.SetESSMaxDischargingLimit(dischargeLimit); err != nil {
+ return fmt.Errorf("failed to set ESS discharging limit: %w", err)
+ }
+
+ } else {
+ // Battery should stay idle - MPC wants to maintain SOC and use grid import/export
+ // Set minimal charge/discharge limits to prevent battery participation
+ // Use mode 4 (command charging) with minimal limits to keep battery idle
+ mode = 4
+ minimalLimit := 0.0 // Zero limit to keep battery completely idle
+ s.logger.Printf("Setting battery to IDLE mode (minimal limits): GridImport: %.1f kW, GridExport: %.1f kW",
+ decision.GridImport, decision.GridExport)
+
+ // Set Remote EMS control mode
+ if err := client.SetRemoteEMSMode(mode); err != nil {
+ return fmt.Errorf("failed to set remote EMS mode: %w", err)
+ }
+
+ // Set minimal charging and discharging limits to effectively disable battery use
+ if err := client.SetESSMaxChargingLimit(minimalLimit); err != nil {
+ return fmt.Errorf("failed to set ESS charging limit: %w", err)
+ }
+ if err := client.SetESSMaxDischargingLimit(minimalLimit); err != nil {
+ return fmt.Errorf("failed to set ESS discharging limit: %w", err)
+ }
+ }
+
+ s.logger.Printf("Successfully executed MPC decision - Mode: %d, SOC: %.1f%%, GridImport: %.1f kW, GridExport: %.1f kW",
+ mode, decision.BatterySOC*100, decision.GridImport, decision.GridExport)
+
return nil
}
+
+// runMPCExecution re-executes the current MPC decision only if previous execution failed
+// This ensures the decision is applied even if previous execution failed
+func (s *MinerScheduler) runMPCExecution() {
+
+ // Check if last execution failed
+ s.mu.RLock()
+ lastError := s.lastMPCExecutionError
+ if lastError == nil {
+ // Last execution succeeded, no need to retry
+ s.mu.RUnlock()
+ return
+ }
+
+ config := s.GetConfig()
+
+ // Check if Plant Modbus Address is configured
+ if config.PlantModbusAddress == "" {
+ s.mu.RUnlock()
+ return
+ }
+
+ // Get current MPC decision
+ if len(s.mpcDecisions) == 0 {
+ s.mu.RUnlock()
+ return
+ }
+ currentDecision := s.mpcDecisions[0]
+ s.mu.RUnlock()
+
+ s.logger.Printf("Retrying MPC decision execution (previous error: %v)", lastError)
+
+ // Execute the current decision
+ err := s.executeMPCDecision(¤tDecision, config.DryRun)
+
+ s.mu.Lock()
+ s.lastMPCExecutionError = err
+ s.mu.Unlock()
+
+ if err != nil {
+ s.logger.Printf("Error retrying MPC decision: %v (will retry again in 1 minute)", err)
+ return
+ }
+
+ s.logger.Printf("Successfully retried MPC decision execution")
+}
diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go
index 3ecd103..acd71ec 100644
--- a/scheduler/scheduler.go
+++ b/scheduler/scheduler.go
@@ -81,7 +81,8 @@ type MinerScheduler struct {
weatherCache WeatherForecastCache
// MPC optimization results
- mpcDecisions []mpc.ControlDecision
+ mpcDecisions []mpc.ControlDecision
+ lastMPCExecutionError error // Tracks if last MPC execution failed
// Web server
webServer *WebServer
@@ -171,6 +172,8 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
if s.config.DryRun {
s.logger.Printf("DRY-RUN MODE ENABLED: Actions will be simulated only")
+ } else {
+ s.GetMarketData(ctx) //nolint:gosec
}
// Start web server if configured
@@ -207,6 +210,7 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
minersControlInitialDelay := s.getInitialDelay(now, config.CheckPriceInterval) + time.Second
pvDataInitialDelay := s.getInitialDelay(now, config.PVIntegrationPeriod)
stateCheckInitialDelay := s.getInitialDelay(now, config.MinersStateCheckInterval)
+ mpcExecutionInitialDelay := s.getInitialDelay(now, config.MPCExecutionInterval) + 2*time.Second
// Create periodic tasks
tasks := []PeriodicTask{
@@ -215,7 +219,7 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
initialDelay: 0, // Run immediately
interval: config.MinerDiscoveryInterval,
runFunc: func() {
- s.runMinerDiscovery(ctx)
+ s.RunMinerDiscovery(ctx)
},
},
{
@@ -224,7 +228,7 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
interval: config.CheckPriceInterval,
runFunc: func() {
s.runPriceCheck(ctx)
- s.runMPCOptimize(ctx)
+ s.RunMPCOptimize(ctx)
},
},
{
@@ -251,6 +255,14 @@ func (s *MinerScheduler) Start(ctx context.Context, serverOnly bool) error {
s.runDataIntegration(dataSamples, config.PVPollInterval, dataDB, config.DeviceID, config.DryRun)
},
},
+ {
+ name: "MPCExecution",
+ initialDelay: mpcExecutionInitialDelay,
+ interval: config.MPCExecutionInterval,
+ runFunc: func() {
+ s.runMPCExecution()
+ },
+ },
}
// Start each periodic task in its own goroutine
diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go
index 5381698..816cc76 100644
--- a/scheduler/scheduler_test.go
+++ b/scheduler/scheduler_test.go
@@ -103,6 +103,7 @@ func testConfigWithServer(server *httptest.Server) *Config {
PVIntegrationPeriod: 15 * time.Minute,
APITimeout: 5 * time.Second,
MinerTimeout: 5 * time.Second,
+ MPCExecutionInterval: time.Minute,
Location: "Europe/Riga",
SecurityToken: "test-token",
UrlFormat: urlFormat,
diff --git a/test_data/Energy_Prices_202601032300-202601042300.xml b/test_data/Energy_Prices_202601032300-202601042300.xml
new file mode 100644
index 0000000..1d741c5
--- /dev/null
+++ b/test_data/Energy_Prices_202601032300-202601042300.xml
@@ -0,0 +1,413 @@
+
+