Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/signal"
"syscall"
"time"

"github.com/devskill-org/ems/scheduler"
"github.com/devskill-org/ems/sigenergy"
Expand All @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
67 changes: 31 additions & 36 deletions mpc/mpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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++ {
Expand All @@ -163,17 +165,24 @@ 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
}{
{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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading