diff --git a/README.md b/README.md index c8544a6..41c8b2d 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,10 @@ The Energy Management System operates on multiple time scales: 4. **Fault Detection**: Identifies and logs device issues ### Energy Optimization (15-minute intervals, configurable) -1. **Price Updates**: Fetches current and forecasted electricity prices +1. **Price Updates**: Fetches current and forecasted electricity prices at 15-minute intervals 2. **Weather Forecast**: Updates solar production forecast -3. **MPC Optimization**: Calculates optimal battery and load schedules for next 24-48 hours -4. **Schedule Execution**: Implements optimized decisions for current period +3. **MPC Optimization**: Calculates optimal battery and load schedules for next 24-48 hours using exact 15-minute price data (not hourly averages) +4. **Schedule Execution**: Implements optimized decisions for current 15-minute period 5. **Load Management**: - **Price below threshold**: Activate loads, prioritize self-consumption - **Price above threshold**: Deactivate non-essential loads, maximize export diff --git a/entsoe/energy_prices_decoder.go b/entsoe/energy_prices_decoder.go index c51f4f4..54ec43e 100644 --- a/entsoe/energy_prices_decoder.go +++ b/entsoe/energy_prices_decoder.go @@ -325,17 +325,6 @@ func (pmd *PublicationMarketData) LookupPriceByTime(t time.Time) (float64, bool) return 0, false } -// LookupAveragePriceInHourByTime searches all TimeSeries in the market data for the average price within the hour containing the given time. -// Returns the first matching average price found and true, or 0 and false if no price is found in any TimeSeries for that hour. -func (pmd *PublicationMarketData) LookupAveragePriceInHourByTime(t time.Time) (float64, bool) { - for _, timeSeries := range pmd.TimeSeries { - if avg, found := timeSeries.Period.averagePriceInHourByTime(t); found { - return avg, true - } - } - return 0, false -} - // GetPriceByTime returns the price for a specific time. // The price corresponds to the interval that contains the given time. // For example, if the period starts at 22:00 with hourly resolution: @@ -412,44 +401,6 @@ func (p *Period) GetTimeRangeForPosition(position int) (start, end time.Time, va return start, end, true } -// averagePriceInHourByTime returns the average price for all intervals within the hour containing the given time. -// If no intervals overlap with the hour, returns (0, false). -func (p *Period) averagePriceInHourByTime(t time.Time) (float64, bool) { - // Find the hour boundaries for the given time - hourStart := t.Truncate(time.Hour) - hourEnd := hourStart.Add(time.Hour) - - var sum float64 - var count int - - var checked *Point - - for _, point := range p.Points { - start, end, valid := p.GetTimeRangeForPosition(point.Position) - if !valid { - continue - } - // Check if the interval overlaps with the hour - if (start.Before(hourEnd) && end.After(hourStart)) || (start.Equal(hourStart) && end.After(hourStart)) { - // correctly calculate average even if data point is missed when the price isn't changed - if checked != nil { - for position := checked.Position + 1; position < point.Position; position++ { - sum += checked.PriceAmount - count++ - } - } - sum += point.PriceAmount - count++ - checked = &point - } - } - - if count == 0 { - return 0, false - } - return sum / float64(count), true -} - // DecodeEnergyPricesXML decodes the XML file and returns the parsed data func DecodeEnergyPricesXML(file io.Reader) (*PublicationMarketData, error) { diff --git a/entsoe/energy_prices_decoder_test.go b/entsoe/energy_prices_decoder_test.go index 19c4ad6..aad92d4 100644 --- a/entsoe/energy_prices_decoder_test.go +++ b/entsoe/energy_prices_decoder_test.go @@ -628,344 +628,3 @@ func TestDocumentDecode(t *testing.T) { t.Errorf("Returned price: %f, want %f", price, 57.73) } } - -// TestAveragePriceInHourByTime tests the averagePriceInHourByTime method on Period. -func TestAveragePriceInHourByTime(t *testing.T) { - periodStart := time.Date(2023, 6, 1, 10, 0, 0, 0, time.UTC) - periodEnd := periodStart.Add(3 * time.Hour) - period := Period{ - TimeInterval: TimeInterval{ - Start: periodStart, - End: periodEnd, - }, - Resolution: time.Hour, - Points: []Point{ - {Position: 1, PriceAmount: 10.0}, // 10:00-11:00 - {Position: 2, PriceAmount: 20.0}, // 11:00-12:00 - {Position: 3, PriceAmount: 30.0}, // 12:00-13:00 - }, - } - - tests := []struct { - name string - queryTime time.Time - expected float64 - found bool - }{ - { - name: "Hour with one price (10:00)", - queryTime: time.Date(2023, 6, 1, 10, 15, 0, 0, time.UTC), - expected: 10.0, - found: true, - }, - { - name: "Hour with one price (11:00)", - queryTime: time.Date(2023, 6, 1, 11, 30, 0, 0, time.UTC), - expected: 20.0, - found: true, - }, - { - name: "Hour with one price (12:00)", - queryTime: time.Date(2023, 6, 1, 12, 45, 0, 0, time.UTC), - expected: 30.0, - found: true, - }, - { - name: "Hour with no price (13:00)", - queryTime: time.Date(2023, 6, 1, 13, 15, 0, 0, time.UTC), - expected: 0.0, - found: false, - }, - { - name: "Hour overlapping two intervals (custom 30-min resolution)", - queryTime: time.Date(2023, 6, 1, 11, 0, 0, 0, time.UTC), - expected: 20.0, - found: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - avg, ok := period.averagePriceInHourByTime(tt.queryTime) - if ok != tt.found { - t.Errorf("expected found=%v, got %v", tt.found, ok) - } - if ok && avg != tt.expected { - t.Errorf("expected average %.2f, got %.2f", tt.expected, avg) - } - }) - } - - // Test with multiple points in the same hour (simulate 30-min resolution) - period30 := Period{ - TimeInterval: TimeInterval{ - Start: periodStart, - End: periodStart.Add(2 * time.Hour), - }, - Resolution: 30 * time.Minute, - Points: []Point{ - {Position: 1, PriceAmount: 10.0}, // 10:00-10:30 - {Position: 2, PriceAmount: 14.0}, // 10:30-11:00 - {Position: 3, PriceAmount: 20.0}, // 11:00-11:30 - {Position: 4, PriceAmount: 22.0}, // 11:30-12:00 - }, - } - - t.Run("Hour with two prices (10:00)", func(t *testing.T) { - avg, ok := period30.averagePriceInHourByTime(time.Date(2023, 6, 1, 10, 45, 0, 0, time.UTC)) - expected := (10.0 + 14.0) / 2 - if !ok { - t.Errorf("expected found=true, got false") - } - if avg != expected { - t.Errorf("expected average %.2f, got %.2f", expected, avg) - } - }) - - t.Run("Hour with two prices (11:00)", func(t *testing.T) { - avg, ok := period30.averagePriceInHourByTime(time.Date(2023, 6, 1, 11, 15, 0, 0, time.UTC)) - expected := (20.0 + 22.0) / 2 - if !ok { - t.Errorf("expected found=true, got false") - } - if avg != expected { - t.Errorf("expected average %.2f, got %.2f", expected, avg) - } - }) - - t.Run("Hour with no prices (12:00)", func(t *testing.T) { - avg, ok := period30.averagePriceInHourByTime(time.Date(2023, 6, 1, 12, 15, 0, 0, time.UTC)) - if ok { - t.Errorf("expected found=false, got true") - } - if avg != 0.0 { - t.Errorf("expected average 0.0, got %.2f", avg) - } - }) - // Test with 2-hour period, 15-min resolution, some duplicate PriceAmount values - period15 := Period{ - TimeInterval: TimeInterval{ - Start: periodStart, - End: periodStart.Add(2 * time.Hour), - }, - Resolution: 15 * time.Minute, - Points: []Point{ - {Position: 1, PriceAmount: 5.0}, // 10:00-10:15 - {Position: 2, PriceAmount: 5.0}, // 10:15-10:30 - {Position: 3, PriceAmount: 10.0}, // 10:30-10:45 - {Position: 4, PriceAmount: 15.0}, // 10:45-11:00 - {Position: 5, PriceAmount: 20.0}, // 11:00-11:15 - // {Position: 6, PriceAmount: 20.0}, // 11:15-11:30 - missed in the actual XMLs if the PriceAmount the same - {Position: 7, PriceAmount: 25.0}, // 11:30-11:45 - {Position: 8, PriceAmount: 30.0}, // 11:45-12:00 - }, - } - - t.Run("Hour with four prices, two duplicates (10:00)", func(t *testing.T) { - avg, ok := period15.averagePriceInHourByTime(time.Date(2023, 6, 1, 10, 30, 0, 0, time.UTC)) - expected := (5.0 + 5.0 + 10.0 + 15.0) / 4 - if !ok { - t.Errorf("expected found=true, got false") - } - if avg != expected { - t.Errorf("expected average %.2f, got %.2f", expected, avg) - } - }) - - t.Run("Hour with four prices, two duplicates (11:00)", func(t *testing.T) { - avg, ok := period15.averagePriceInHourByTime(time.Date(2023, 6, 1, 11, 45, 0, 0, time.UTC)) - expected := (20.0 + 20.0 + 25.0 + 30.0) / 4 - if !ok { - t.Errorf("expected found=true, got false") - } - if avg != expected { - t.Errorf("expected average %.2f, got %.2f", expected, avg) - } - }) -} - -// TestLookupAveragePriceInHourByTime_MergedData tests that LookupAveragePriceInHourByTime -// works correctly with merged data from multiple days -func TestLookupAveragePriceInHourByTime_MergedData(t *testing.T) { - // Create a document representing merged data from two consecutive days - // Day 1: June 1, 2024, 00:00 - June 2, 2024, 00:00 (24 hours) - // Day 2: June 2, 2024, 00:00 - June 3, 2024, 00:00 (24 hours) - - day1Start := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) - day1End := time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC) - day2Start := time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC) - day2End := time.Date(2024, 6, 3, 0, 0, 0, 0, time.UTC) - - // Create merged document with two time series (one for each day) - mergedDoc := &PublicationMarketData{ - MRID: "merged-doc", - RevisionNumber: 1, - Type: "A44", - PeriodTimeInterval: TimeInterval{ - Start: day1Start, - End: day2End, // Spans both days - }, - TimeSeries: []TimeSeries{ - // First day's data - { - MRID: "ts-day1", - BusinessType: "A62", - Period: Period{ - TimeInterval: TimeInterval{ - Start: day1Start, - End: day1End, - }, - Resolution: time.Hour, - Points: []Point{ - {Position: 1, PriceAmount: 45.50}, // June 1, 00:00-01:00 - {Position: 2, PriceAmount: 42.30}, // June 1, 01:00-02:00 - {Position: 3, PriceAmount: 40.00}, // June 1, 02:00-03:00 - {Position: 13, PriceAmount: 55.00}, // June 1, 12:00-13:00 - {Position: 14, PriceAmount: 60.00}, // June 1, 13:00-14:00 - {Position: 15, PriceAmount: 58.00}, // June 1, 14:00-15:00 - {Position: 23, PriceAmount: 50.00}, // June 1, 22:00-23:00 - {Position: 24, PriceAmount: 48.00}, // June 1, 23:00-00:00 - }, - }, - }, - // Second day's data - { - MRID: "ts-day2", - BusinessType: "A62", - Period: Period{ - TimeInterval: TimeInterval{ - Start: day2Start, - End: day2End, - }, - Resolution: time.Hour, - Points: []Point{ - {Position: 1, PriceAmount: 46.00}, // June 2, 00:00-01:00 - {Position: 2, PriceAmount: 44.00}, // June 2, 01:00-02:00 - {Position: 3, PriceAmount: 43.00}, // June 2, 02:00-03:00 - {Position: 13, PriceAmount: 57.00}, // June 2, 12:00-13:00 - {Position: 14, PriceAmount: 62.00}, // June 2, 13:00-14:00 - {Position: 15, PriceAmount: 59.00}, // June 2, 14:00-15:00 - {Position: 23, PriceAmount: 52.00}, // June 2, 22:00-23:00 - {Position: 24, PriceAmount: 49.00}, // June 2, 23:00-00:00 - }, - }, - }, - }, - } - - tests := []struct { - name string - queryTime time.Time - expectedPrice float64 - shouldFind bool - }{ - // Test prices from first day - { - name: "First day - hour 0 (00:00)", - queryTime: time.Date(2024, 6, 1, 0, 30, 0, 0, time.UTC), - expectedPrice: 45.50, - shouldFind: true, - }, - { - name: "First day - hour 1 (01:00)", - queryTime: time.Date(2024, 6, 1, 1, 15, 0, 0, time.UTC), - expectedPrice: 42.30, - shouldFind: true, - }, - { - name: "First day - hour 12 (12:00)", - queryTime: time.Date(2024, 6, 1, 12, 45, 0, 0, time.UTC), - expectedPrice: 55.00, - shouldFind: true, - }, - { - name: "First day - hour 13 (13:00)", - queryTime: time.Date(2024, 6, 1, 13, 30, 0, 0, time.UTC), - expectedPrice: 60.00, - shouldFind: true, - }, - { - name: "First day - hour 22 (22:00)", - queryTime: time.Date(2024, 6, 1, 22, 0, 0, 0, time.UTC), - expectedPrice: 50.00, - shouldFind: true, - }, - { - name: "First day - hour 23 (23:00)", - queryTime: time.Date(2024, 6, 1, 23, 59, 0, 0, time.UTC), - expectedPrice: 48.00, - shouldFind: true, - }, - // Test prices from second day - { - name: "Second day - hour 0 (00:00)", - queryTime: time.Date(2024, 6, 2, 0, 30, 0, 0, time.UTC), - expectedPrice: 46.00, - shouldFind: true, - }, - { - name: "Second day - hour 1 (01:00)", - queryTime: time.Date(2024, 6, 2, 1, 15, 0, 0, time.UTC), - expectedPrice: 44.00, - shouldFind: true, - }, - { - name: "Second day - hour 12 (12:00)", - queryTime: time.Date(2024, 6, 2, 12, 45, 0, 0, time.UTC), - expectedPrice: 57.00, - shouldFind: true, - }, - { - name: "Second day - hour 13 (13:00)", - queryTime: time.Date(2024, 6, 2, 13, 30, 0, 0, time.UTC), - expectedPrice: 62.00, - shouldFind: true, - }, - { - name: "Second day - hour 14 (14:00)", - queryTime: time.Date(2024, 6, 2, 14, 0, 0, 0, time.UTC), - expectedPrice: 59.00, - shouldFind: true, - }, - { - name: "Second day - hour 22 (22:00)", - queryTime: time.Date(2024, 6, 2, 22, 30, 0, 0, time.UTC), - expectedPrice: 52.00, - shouldFind: true, - }, - // Test times outside the data range - { - name: "Before first day", - queryTime: time.Date(2024, 5, 31, 23, 30, 0, 0, time.UTC), - expectedPrice: 0.0, - shouldFind: false, - }, - { - name: "After second day", - queryTime: time.Date(2024, 6, 3, 0, 30, 0, 0, time.UTC), - expectedPrice: 0.0, - shouldFind: false, - }, - // Test hours without data in the sparse dataset - { - name: "First day - hour 10 (no data)", - queryTime: time.Date(2024, 6, 1, 10, 30, 0, 0, time.UTC), - expectedPrice: 0.0, - shouldFind: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - price, found := mergedDoc.LookupAveragePriceInHourByTime(tt.queryTime) - - if found != tt.shouldFind { - t.Errorf("Expected found=%v, got %v", tt.shouldFind, found) - } - - if found && price != tt.expectedPrice { - t.Errorf("Expected price %.2f, got %.2f", tt.expectedPrice, price) - } - }) - } -} diff --git a/mpc/example/main.go b/mpc/example/main.go index a3539f1..6c6e967 100644 --- a/mpc/example/main.go +++ b/mpc/example/main.go @@ -24,10 +24,14 @@ func main() { MaxGridExport: 10.0, // 10 kW } - // Create MPC controller with 24-hour horizon - mpcController := mpc.NewController(config, 24, 0.5) // Start at 50% SOC - - // Create 24-hour forecast + // Create MPC controller with 24-hour horizon using hourly time slots + // Note: In production, this can be configured to use 15-minute intervals (96 slots for 24 hours) + // or any other interval based on check_price_interval configuration + mpcController := mpc.NewController(config, 24, 0.5) // 24 hourly slots, start at 50% SOC + + // Create 24-hour forecast with hourly time slots + // Note: For 15-minute intervals, you would create 96 slots (24 hours * 4 slots/hour) + // with prices and forecasts for each 15-minute period forecast := make([]mpc.TimeSlot, 24) for i := range 24 { // Example: cheap at night, expensive during day @@ -65,9 +69,10 @@ func main() { } // Run optimization - fmt.Println("Solar Inverter MPC Optimization") - fmt.Println("================================") - fmt.Printf("Initial SOC: %.1f%%\n\n", mpcController.CurrentSOC*100) + fmt.Println("Solar Inverter MPC Optimization (Hourly Example)") + fmt.Println("=================================================") + fmt.Printf("Initial SOC: %.1f%%\n", mpcController.CurrentSOC*100) + fmt.Printf("Time slots: %d (hourly intervals)\n\n", len(forecast)) startTime := time.Now() decisions := mpcController.Optimize(forecast) @@ -120,4 +125,7 @@ func main() { fmt.Println("- Charges battery from solar during day") fmt.Println("- Discharges battery during expensive peak hours") fmt.Println("- Exports excess solar when prices are favorable") + fmt.Println("\nNote: This example uses hourly time slots.") + fmt.Println("For 15-minute intervals (as configured in production with check_price_interval=15m),") + fmt.Println("create 96 time slots with specific prices for each 15-minute period.") } diff --git a/mpc/mpc.go b/mpc/mpc.go index 603c4f0..be36c8c 100644 --- a/mpc/mpc.go +++ b/mpc/mpc.go @@ -18,35 +18,35 @@ type SystemConfig struct { MaxGridExport float64 // kW } -// TimeSlot represents one hour of operation +// TimeSlot represents one time period of operation (typically 15 minutes, configurable via check_price_interval) type TimeSlot struct { Hour int Timestamp int64 // Unix timestamp when this time slot begins ImportPrice float64 // $/kWh ExportPrice float64 // $/kWh - SolarForecast float64 // kW average for the hour - LoadForecast float64 // kW average for the hour + SolarForecast float64 // kW average for the time period + LoadForecast float64 // kW average for the time period CloudCoverage float64 // % cloud coverage (0-100) WeatherSymbol string // weather condition symbol } -// ControlDecision represents the optimal control for one time slot +// ControlDecision represents the optimal control for one time slot (typically 15 minutes, configurable via check_price_interval) type ControlDecision struct { - Hour int - Timestamp int64 // Unix timestamp when this time slot begins - BatteryCharge float64 // kW (positive = charging) - DEPRECATED: use BatteryChargeFromPV + BatteryChargeFromGrid - BatteryChargeFromPV float64 // kW (positive = charging from PV surplus) + Hour int + Timestamp int64 // Unix timestamp when this time slot begins + BatteryCharge float64 // kW (positive = charging) - DEPRECATED: use BatteryChargeFromPV + BatteryChargeFromGrid + BatteryChargeFromPV float64 // kW (positive = charging from PV surplus) BatteryChargeFromGrid float64 // kW (positive = charging from grid) - BatteryDischarge float64 // kW (positive = discharging) - GridImport float64 // kW (positive = importing) - GridExport float64 // kW (positive = exporting) - BatterySOC float64 // percentage (0-1) - Profit float64 // $ for this hour + BatteryDischarge float64 // kW (positive = discharging) + GridImport float64 // kW (positive = importing) + GridExport float64 // kW (positive = exporting) + BatterySOC float64 // percentage (0-1) + Profit float64 // $ for this time period // Forecast data used for this decision ImportPrice float64 // $/kWh ExportPrice float64 // $/kWh - SolarForecast float64 // kW average for the hour - LoadForecast float64 // kW average for the hour + SolarForecast float64 // kW average for the time period + LoadForecast float64 // kW average for the time period CloudCoverage float64 // % cloud coverage (0-100) WeatherSymbol string // weather condition symbol } @@ -54,7 +54,7 @@ type ControlDecision struct { // Controller implements Model Predictive Control type Controller struct { Config SystemConfig - Horizon int // hours to look ahead + Horizon int // number of time periods to look ahead CurrentSOC float64 } diff --git a/scheduler/data.go b/scheduler/data.go index 4ccb872..fddc4a6 100644 --- a/scheduler/data.go +++ b/scheduler/data.go @@ -240,7 +240,7 @@ func (s *MinerScheduler) runDataIntegration(samples *DataSamples, pollInterval t s.mu.RUnlock() if marketData != nil { - spotPrice, found := marketData.LookupAveragePriceInHourByTime(timestamp) + spotPrice, found := marketData.LookupPriceByTime(timestamp) if found && spotPrice > 0 { // Import cost: (spot price + operator fee + delivery fee) * energy in MWh importPricePerMWh := spotPrice + config.ImportPriceOperatorFee + config.ImportPriceDeliveryFee diff --git a/scheduler/mpc.go b/scheduler/mpc.go index 724973f..7754143 100644 --- a/scheduler/mpc.go +++ b/scheduler/mpc.go @@ -92,7 +92,11 @@ func (s *MinerScheduler) RunMPCOptimize(ctx context.Context) error { for _, dec := range decisions { totalProfit += dec.Profit } - s.logger.Printf("Total expected profit over %d hours: %.2f EUR", len(decisions), totalProfit) + + // Calculate forecast duration based on check_price_interval and number of decisions + forecastDuration := config.CheckPriceInterval * time.Duration(len(decisions)) + s.logger.Printf("Total expected profit over %d time periods (%.1f hours): %.2f EUR", + len(decisions), forecastDuration.Hours(), totalProfit) // Step 6: Execute the first control decision err = s.executeMPCDecision(&decisions[0], config.DryRun) @@ -136,13 +140,17 @@ func (s *MinerScheduler) readPlantRunningInfo(config *Config) (*sigenergy.PlantR } // buildMPCForecast builds the forecast data needed for MPC optimization +// buildMPCForecast builds a forecast for MPC optimization combining prices, solar, and load func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config, plantInfo *sigenergy.PlantRunningInfo) ([]mpc.TimeSlot, error) { now := time.Now() - // Get electricity price forecast - priceForecasts, err := s.getPriceForecast(ctx, now) + // Get the market data for price lookups + marketData, err := s.GetMarketData(ctx) if err != nil { - return nil, fmt.Errorf("failed to get price forecast: %w", err) + return nil, fmt.Errorf("failed to get market data: %w", err) + } + if marketData == nil { + return nil, fmt.Errorf("no price document available") } // Get weather forecast for weather data @@ -152,30 +160,66 @@ func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config, p weatherForecast = nil } - // Get solar forecast with weather data - solarForecasts, weatherData, err := s.getSolarForecast(config, now, weatherForecast, plantInfo) - if err != nil { - s.logger.Printf("Warning: failed to get solar forecast: %v, using zero solar", err) - // Continue with zero solar forecast + // Pre-compute hourly solar and weather forecasts (cache for efficiency) + // Solar forecasts are typically hourly, so we compute them once and reuse for all 15-min slots in each hour + var solarForecasts map[int]float64 + var weatherData map[int]WeatherData + if weatherForecast != nil { + solarForecasts, weatherData, err = s.getSolarForecast(config, now, weatherForecast, plantInfo) + if err != nil { + s.logger.Printf("Warning: failed to get solar forecast: %v, using zero solar", err) + solarForecasts = make(map[int]float64) + weatherData = make(map[int]WeatherData) + } + } else { solarForecasts = make(map[int]float64) weatherData = make(map[int]WeatherData) } - // Build time slots + // Determine time slot duration based on CheckPriceInterval + // Default to 15 minutes if not configured + slotDuration := config.CheckPriceInterval + if slotDuration == 0 { + slotDuration = 15 * time.Minute + } + + // Calculate number of slots for next 24-48 hours + // Use 36 hours to have enough forecast horizon + forecastDuration := 36 * time.Hour + numSlots := int(forecastDuration / slotDuration) + + // Build time slots at the configured interval var timeSlots []mpc.TimeSlot - for hour, prices := range priceForecasts { - solar := solarForecasts[hour] - weather := weatherData[hour] + for i := range numSlots { + futureTime := now.Add(time.Duration(i) * slotDuration) + + // Get exact price for this time slot using LookupPriceByTime + // This will return the price for the specific 15-minute interval + var importPrice, exportPrice float64 + if spotPrice, found := marketData.LookupPriceByTime(futureTime); found { + // Apply price adjustments from configuration (all values in EUR/MWh) + importPrice = (spotPrice + config.ImportPriceOperatorFee + config.ImportPriceDeliveryFee) / 1000.0 // Convert to EUR/kWh + exportPrice = (spotPrice - config.ExportPriceOperatorFee) / 1000.0 // Convert to EUR/kWh + } else { + // No price available for this time slot, skip it + continue + } + + // Get solar forecast for this time period + // Since solar forecasts are typically hourly, we use the forecast for the containing hour + // All 15-minute slots within the same hour will use the same solar/weather forecast + hourIndex := int(futureTime.Sub(now).Hours()) + solar := solarForecasts[hourIndex] + weather := weatherData[hourIndex] // Estimate load forecast (miners only, based on price and solar availability) - loadForecast := s.estimateLoadForecast(prices.Import, config.PriceLimit/1000, solar, config) + loadForecast := s.estimateLoadForecast(importPrice*1000.0, config.PriceLimit/1000, solar, config) - futureTime := now.Add(time.Duration(hour) * time.Hour) timeSlots = append(timeSlots, mpc.TimeSlot{ - Hour: hour, + Hour: i, // Now represents time slot index, not hour Timestamp: futureTime.Unix(), - ImportPrice: prices.Import / 1000.0, // Convert EUR/MWh to EUR/kWh - ExportPrice: prices.Export / 1000.0, // Convert EUR/MWh to EUR/kWh + ImportPrice: importPrice, + ExportPrice: exportPrice, SolarForecast: solar, LoadForecast: loadForecast, CloudCoverage: weather.CloudCoverage, @@ -183,15 +227,6 @@ func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config, p }) } - // Sort by hour - for i := 0; i < len(timeSlots); i++ { - for j := i + 1; j < len(timeSlots); j++ { - if timeSlots[i].Hour > timeSlots[j].Hour { - timeSlots[i], timeSlots[j] = timeSlots[j], timeSlots[i] - } - } - } - return timeSlots, nil } @@ -201,47 +236,6 @@ type WeatherData struct { WeatherSymbol string // weather condition symbol } -// PricePoint represents import and export prices for a specific hour -type PricePoint struct { - Import float64 // EUR/MWh - Export float64 // EUR/MWh -} - -// 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 - marketData, err := s.GetMarketData(ctx) - if err != nil { - return nil, err - } - if marketData == nil { - return nil, fmt.Errorf("no price document available") - } - - // Get configuration for price adjustments - config := s.GetConfig() - - // Extract prices for next 36 hours - forecast := make(map[int]PricePoint) - for i := range 36 { - futureTime := now.Add(time.Duration(i) * time.Hour) - price, found := marketData.LookupAveragePriceInHourByTime(futureTime) - - if found { - // Apply price adjustments from configuration (all values in EUR/MWh) - // Import price: add operator fee and delivery fee - // Export price: subtract operator fee - forecast[i] = PricePoint{ - Import: price + config.ImportPriceOperatorFee + config.ImportPriceDeliveryFee, - Export: price - config.ExportPriceOperatorFee, - } - } - } - - return forecast, nil -} - // getSolarForecast gets solar power forecast from weather data func (s *MinerScheduler) getSolarForecast(config *Config, now time.Time, weatherForecast *meteo.METJSONForecast, plantInfo *sigenergy.PlantRunningInfo) (map[int]float64, map[int]WeatherData, error) { if weatherForecast == nil || weatherForecast.Properties == nil { @@ -477,7 +471,7 @@ func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRu // Battery should charge // Use BatteryChargeFromPV as the charge limit chargeLimit := decision.BatteryChargeFromPV - + // Decide mode based on whether grid charging is needed if decision.BatteryChargeFromGrid > 0.01 { // Mode 4: Command charging (PV first, then grid) - charge from PV and grid if needed diff --git a/scheduler/pricing.go b/scheduler/pricing.go index 926fd56..ce133cb 100644 --- a/scheduler/pricing.go +++ b/scheduler/pricing.go @@ -71,13 +71,13 @@ func (s *MinerScheduler) runPriceCheck(ctx context.Context) error { s.logger.Printf("Starting price check task at %s", time.Now().Format(time.RFC3339)) // Step 1: Get current electricity price - currentPrice, err := s.getCurrentAvgPrice(ctx) + currentPrice, err := s.getCurrentPrice(ctx) if err != nil { s.logger.Printf("Error getting current price: %v", err) return err } - s.logger.Printf("Current hourly average electricity price: %.2f EUR/MWh", currentPrice) + s.logger.Printf("Current electricity price: %.2f EUR/MWh", currentPrice) s.logger.Printf("Price limit: %.2f EUR/MWh", s.config.PriceLimit) // Step 2: Manage miners based on price @@ -90,8 +90,8 @@ func (s *MinerScheduler) runPriceCheck(ctx context.Context) error { return nil } -// getCurrentAvgPrice gets the current hourly average electricity price, downloading new data if needed -func (s *MinerScheduler) getCurrentAvgPrice(ctx context.Context) (float64, error) { +// getCurrentPrice gets the current electricity price at the exact time, downloading new data if needed +func (s *MinerScheduler) getCurrentPrice(ctx context.Context) (float64, error) { location, err := time.LoadLocation(s.config.Location) if err != nil { return 0, fmt.Errorf("failed to load location: %w", err) @@ -104,7 +104,7 @@ func (s *MinerScheduler) getCurrentAvgPrice(ctx context.Context) (float64, error return 0, fmt.Errorf("failed to get market prices: %w", err) } - if price, found := marketData.LookupAveragePriceInHourByTime(now); found { + if price, found := marketData.LookupPriceByTime(now); found { s.logger.Printf("Price found: %.2f EUR/MWh", price) return price, nil } diff --git a/scheduler/pricing_test.go b/scheduler/pricing_test.go index 9ae65bb..4b3bed6 100644 --- a/scheduler/pricing_test.go +++ b/scheduler/pricing_test.go @@ -10,10 +10,10 @@ import ( "time" ) -// TestGetCurrentAvgPrice_UsesConfiguredTimezone validates that getCurrentAvgPrice uses the configured timezone -// This test validates the fix for the timezone mismatch issue where getCurrentAvgPrice was using time.Now() +// TestGetCurrentPrice_UsesConfiguredTimezone validates that getCurrentPrice uses the configured timezone +// This test validates the fix for the timezone mismatch issue where getCurrentPrice was using time.Now() // without timezone conversion, while GetMarketData was using timezone-aware time. -func TestGetCurrentAvgPrice_UsesConfiguredTimezone(t *testing.T) { +func TestGetCurrentPrice_UsesConfiguredTimezone(t *testing.T) { // Load test data xmlData, err := os.ReadFile("../test_data/Energy_Prices_202509052100-202509062100.xml") if err != nil { @@ -64,7 +64,7 @@ func TestGetCurrentAvgPrice_UsesConfiguredTimezone(t *testing.T) { } // Verify we can find a price for our test time using the market data directly - price, found := marketData.LookupAveragePriceInHourByTime(testTime) + price, found := marketData.LookupPriceByTime(testTime) if !found { t.Fatalf("Price not found for test time %s in timezone %s", testTime.Format(time.RFC3339), location) } @@ -79,8 +79,8 @@ func TestGetCurrentAvgPrice_UsesConfiguredTimezone(t *testing.T) { t.Log("Timezone configuration validated successfully") } -// TestGetCurrentAvgPrice_LocationLoading validates that getCurrentAvgPrice correctly loads the timezone -func TestGetCurrentAvgPrice_LocationLoading(t *testing.T) { +// TestGetCurrentPrice_LocationLoading validates that getCurrentPrice correctly loads the timezone +func TestGetCurrentPrice_LocationLoading(t *testing.T) { // Load test data xmlData, err := os.ReadFile("../test_data/Energy_Prices_202509052100-202509062100.xml") if err != nil { @@ -109,10 +109,10 @@ func TestGetCurrentAvgPrice_LocationLoading(t *testing.T) { ctx := context.Background() - // Call getCurrentAvgPrice - it should load the location internally + // Call getCurrentPrice - it should load the location internally // Even though the current time won't match the test data time range, // the function should at least load the timezone correctly and not panic - _, err = scheduler.getCurrentAvgPrice(ctx) + _, err = scheduler.getCurrentPrice(ctx) // We expect an error since current time doesn't match test data // But the important thing is it loaded the timezone correctly @@ -126,11 +126,11 @@ func TestGetCurrentAvgPrice_LocationLoading(t *testing.T) { } } - t.Log("getCurrentAvgPrice successfully loads timezone configuration") + t.Log("getCurrentPrice successfully loads timezone configuration") } -// TestGetCurrentAvgPrice_InvalidLocation validates error handling for invalid timezone -func TestGetCurrentAvgPrice_InvalidLocation(t *testing.T) { +// TestGetCurrentPrice_InvalidLocation validates error handling for invalid timezone +func TestGetCurrentPrice_InvalidLocation(t *testing.T) { // Load test data xmlData, err := os.ReadFile("../test_data/Energy_Prices_202509052100-202509062100.xml") if err != nil { @@ -159,8 +159,8 @@ func TestGetCurrentAvgPrice_InvalidLocation(t *testing.T) { ctx := context.Background() - // Call getCurrentAvgPrice with invalid timezone - should return an error - _, err = scheduler.getCurrentAvgPrice(ctx) + // Call getCurrentPrice with invalid timezone - should return an error + _, err = scheduler.getCurrentPrice(ctx) if err == nil { t.Fatal("Expected error for invalid timezone, got nil") diff --git a/scheduler/server.go b/scheduler/server.go index f23f9e7..f3f1db6 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -567,8 +567,8 @@ func (hs *WebServer) buildStatusData() map[string]any { priceData["document_id"] = doc.MRID priceData["created_at"] = doc.CreatedDateTime - if price, found := doc.LookupAveragePriceInHourByTime(time.Now()); found { - priceData["current_avg_price"] = price + if price, found := doc.LookupPriceByTime(time.Now()); found { + priceData["current_price"] = price priceData["current"] = price priceData["limit"] = hs.scheduler.GetConfig().PriceLimit } diff --git a/sql/mpc_decisions.sql b/sql/mpc_decisions.sql index 92c0710..b4f2aed 100644 --- a/sql/mpc_decisions.sql +++ b/sql/mpc_decisions.sql @@ -19,7 +19,7 @@ CREATE TABLE mpc_decisions ( -- Column descriptions: -- timestamp: Unix timestamp when this time slot begins (PRIMARY KEY) --- hour: Hour index in the optimization horizon (0-based) +-- hour: Time slot index in the optimization horizon (0-based), represents periods based on check_price_interval (e.g., 15-minute intervals) -- battery_charge: Battery charging power in kW (positive = charging) - DEPRECATED: use battery_charge_from_pv + battery_charge_from_grid -- battery_charge_from_pv: Battery charging power from PV surplus in kW -- battery_charge_from_grid: Battery charging power from grid in kW (zero solar scenario) @@ -27,10 +27,10 @@ CREATE TABLE mpc_decisions ( -- grid_import: Grid import power in kW (positive = importing) -- grid_export: Grid export power in kW (positive = exporting) -- battery_soc: Battery state of charge as fraction (0-1) --- profit: Expected profit for this hour in EUR --- import_price: Electricity import price in EUR/kWh --- export_price: Electricity export price in EUR/kWh --- solar_forecast: Forecasted solar generation in kW (average for the hour) --- load_forecast: Forecasted load consumption in kW (average for the hour) +-- profit: Expected profit for this time period in EUR +-- import_price: Electricity import price in EUR/kWh (exact price for this time slot, not averaged) +-- export_price: Electricity export price in EUR/kWh (exact price for this time slot, not averaged) +-- solar_forecast: Forecasted solar generation in kW (average for the time period) +-- load_forecast: Forecasted load consumption in kW (average for the time period) -- cloud_coverage: Cloud coverage percentage (0-100) -- weather_symbol: Weather condition symbol code