From 7f115ae95f2e63fbf5b6a1da4d9c313c7042c7d7 Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Thu, 1 Jan 2026 12:31:03 +0200 Subject: [PATCH 1/3] feat: add solar info --- scheduler/server.go | 22 ++ web/src/App.css | 38 +++ web/src/App.tsx | 402 +++++++--------------------- web/src/components/InfoItem.tsx | 14 + web/src/components/PowerDisplay.tsx | 39 +++ web/src/components/SolarInfo.tsx | 53 ++++ web/src/components/StatusBadge.tsx | 17 ++ web/src/hooks/useWebSocket.ts | 131 +++++++++ web/src/types/api.ts | 59 ++++ web/src/utils/powerUtils.ts | 13 + 10 files changed, 479 insertions(+), 309 deletions(-) create mode 100644 web/src/components/InfoItem.tsx create mode 100644 web/src/components/PowerDisplay.tsx create mode 100644 web/src/components/SolarInfo.tsx create mode 100644 web/src/components/StatusBadge.tsx create mode 100644 web/src/hooks/useWebSocket.ts create mode 100644 web/src/types/api.ts create mode 100644 web/src/utils/powerUtils.ts diff --git a/scheduler/server.go b/scheduler/server.go index 1259dc8..f847334 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -4,11 +4,13 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "sync" "time" "github.com/gorilla/websocket" + "github.com/sixdouglas/suncalc" ) // WebServer provides HTTP endpoints for health checking, monitoring, and web UI @@ -31,6 +33,7 @@ type StatusResponse struct { Scheduler SchedulerHealth `json:"scheduler"` System SystemHealth `json:"system"` EMS EMSHealth `json:"ems"` + Sun SunInfo `json:"sun"` } // SchedulerHealth represents scheduler-specific health information @@ -62,6 +65,13 @@ type EMSHealth struct { PlantActivePower float64 `json:"plant_active_power"` } +// SunInfo represents solar position and timing information +type SunInfo struct { + SolarAngle float64 `json:"solar_angle"` + Sunrise string `json:"sunrise"` + Sunset string `json:"sunset"` +} + // NewWebServer creates a new web server with health endpoints and static file serving func NewWebServer(scheduler *MinerScheduler, port int) *WebServer { if port <= 0 { @@ -381,6 +391,18 @@ func (hs *WebServer) buildStatusData() map[string]any { } } + // Calculate sun information + config := hs.scheduler.GetConfig() + now := time.Now() + sunTimes := suncalc.GetTimes(now, config.Latitude, config.Longitude) + sunPos := suncalc.GetPosition(now, config.Latitude, config.Longitude) + + health.Sun = SunInfo{ + SolarAngle: sunPos.Altitude * 180 / math.Pi, // Convert radians to degrees + Sunrise: sunTimes["sunrise"].Value.Format(time.RFC3339), + Sunset: sunTimes["sunset"].Value.Format(time.RFC3339), + } + priceData := map[string]any{ "has_document": doc != nil, } diff --git a/web/src/App.css b/web/src/App.css index e4ed144..10d3e0f 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -308,6 +308,44 @@ body { gap: 1rem; } +.solar-info { + background-color: rgba(30, 41, 59, 0.95); + border-radius: var(--border-radius); + border: 2px solid var(--color-primary); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + overflow: hidden; +} + +.solar-info-content { + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.solar-info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.35rem 0.5rem; + background-color: rgba(51, 65, 85, 0.4); + border-radius: 4px; + border: 1px solid rgba(51, 65, 85, 0.6); +} + +.solar-info-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.solar-info-value { + font-size: 0.875rem; + font-weight: 700; + color: var(--color-text); + font-family: "Courier New", monospace; +} + @media (max-width: 768px) { .header { flex-direction: column; diff --git a/web/src/App.tsx b/web/src/App.tsx index 85293ff..2eea04f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,173 +1,13 @@ -import { useEffect, useState, useCallback, useRef } from "react"; import "./App.css"; import emsIcon from "../assets/icon-32.png"; - -interface SchedulerStatus { - is_running: boolean; - miners_count: number; - has_market_data: boolean; - price_limit: number; - network: string; -} - -interface HealthResponse { - status: string; - timestamp: string; - version: string; - scheduler: SchedulerStatus; - system: { - uptime: string; - goroutines: number; - }; - ems: { - current_pv_power: number; - ess_power: number; - ess_soc: number; - grid_sensor_status: number; - grid_sensor_active_power: number; - plant_active_power: number; - }; -} - -interface StatusResponse { - scheduler_status: { - is_running: boolean; - miners_count: number; - has_market_data: boolean; - }; - miners: { - count: number; - list: Array<{ - ip: string; - status: string; - }>; - }; - price_data: { - has_document: boolean; - current_avg_price?: number; - current?: number; - limit?: number; - }; - - timestamp: string; -} - -interface WebSocketMessage { - type: string; - health: HealthResponse; - status: StatusResponse; -} +import { InfoItem } from "./components/InfoItem"; +import { StatusBadge } from "./components/StatusBadge"; +import { PowerDisplay } from "./components/PowerDisplay"; +import { SolarInfo } from "./components/SolarInfo"; +import { useWebSocket } from "./hooks/useWebSocket"; function App() { - const [health, setHealth] = useState(null); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [wsConnected, setWsConnected] = useState(false); - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const isConnectingRef = useRef(false); - - const connectWebSocket = useCallback(() => { - // Prevent duplicate connections - if ( - isConnectingRef.current || - (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) - ) { - console.log("Already connecting or connected, skipping..."); - return; - } - - isConnectingRef.current = true; - - // Clear any existing reconnect timeout - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - - // Close existing connection if any - if (wsRef.current) { - wsRef.current.close(); - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/api/ws`; - - console.log("Connecting to WebSocket:", wsUrl); - - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - console.log("WebSocket connected"); - setWsConnected(true); - setError(null); - setLoading(false); - reconnectAttemptsRef.current = 0; - isConnectingRef.current = false; - }; - - ws.onmessage = (event) => { - try { - const data: WebSocketMessage = JSON.parse(event.data); - - if (data.type === "status_update") { - setHealth(data.health); - setStatus(data.status); - setError(null); - } - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - setError("Failed to parse server data"); - } - }; - - ws.onerror = (event) => { - console.error("WebSocket error:", event); - setError("WebSocket connection error"); - setWsConnected(false); - isConnectingRef.current = false; - }; - - ws.onclose = (event) => { - console.log("WebSocket closed:", event.code, event.reason); - setWsConnected(false); - wsRef.current = null; - isConnectingRef.current = false; - - // Attempt to reconnect with exponential backoff - reconnectAttemptsRef.current += 1; - const delay = Math.min( - 1000 * Math.pow(2, reconnectAttemptsRef.current), - 30000, - ); - - console.log( - `Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`, - ); - - reconnectTimeoutRef.current = setTimeout(() => { - connectWebSocket(); - }, delay); - }; - }, []); - - useEffect(() => { - connectWebSocket(); - - // Cleanup on unmount - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { health, status, loading, error, wsConnected } = useWebSocket(); if (loading) { return ( @@ -208,16 +48,16 @@ function App() { Energy Management System
-
- {isHealthy ? "✓ Healthy" : "✗ Unhealthy"} -
-
- {wsConnected ? "🔗 Connected" : "⚠️ Disconnected"} -
+ +
@@ -225,38 +65,28 @@ function App() {

Scheduler Status

-
- Running: - - {health?.scheduler.is_running ? "Yes" : "No"} - -
-
- Network: - {health?.scheduler.network} -
-
- Miners Count: - {status?.miners.count || 0} -
-
- Market Data: - - {health?.scheduler.has_market_data + + + + -
+ : "Not Available" + } + valueClassName={ + health?.scheduler.has_market_data + ? "value-success" + : "value-warning" + } + />
@@ -264,14 +94,14 @@ function App() {

Price Information

-
- Current Avg Price: - {currentPrice.toFixed(2)} €/MWh -
-
- Price Limit: - {priceLimit.toFixed(2)} €/MWh -
+ +
)} @@ -297,111 +127,65 @@ function App() {

Devices

-
-
- {health?.ems?.current_pv_power !== undefined - ? `${health.ems.current_pv_power.toFixed(2)} kW` - : "N/A"} -
-
-
-
0 - ? "#ea580c" - : health.ems.ess_power < 0 - ? "#16a34a" - : "#f1f5f9" - : "#f1f5f9", - }} - > - {health?.ems?.ess_power !== undefined - ? `${health.ems.ess_power.toFixed(2)} kW` - : "N/A"} -
-
- {health?.ems?.ess_soc !== undefined + /> + + -
-
-
0 - ? "#ea580c" - : health.ems.grid_sensor_active_power < 0 - ? "#16a34a" - : "#f1f5f9" - : "#f1f5f9", - }} - > - {health?.ems?.grid_sensor_active_power !== undefined && + : "N/A" + } + style={{ position: "absolute", top: "643px", right: "622px" }} + /> + + -
-
+ + -
Active Power
-
0 - ? "#16a34a" - : "#f1f5f9" - : "#f1f5f9", - }} - > - {health?.ems?.plant_active_power !== undefined - ? `${health.ems.plant_active_power.toFixed(2)} kW` - : "N/A"} -
-
+ /> + +

System Information

-
- Version: - {health?.version} -
-
- Uptime: - {health?.system.uptime} -
-
- Last Updated: - - {status?.timestamp + + + -
+ : "N/A" + } + />
diff --git a/web/src/components/InfoItem.tsx b/web/src/components/InfoItem.tsx new file mode 100644 index 0000000..32c24c4 --- /dev/null +++ b/web/src/components/InfoItem.tsx @@ -0,0 +1,14 @@ +interface InfoItemProps { + label: string; + value: React.ReactNode; + valueClassName?: string; +} + +export function InfoItem({ label, value, valueClassName }: InfoItemProps) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/components/PowerDisplay.tsx b/web/src/components/PowerDisplay.tsx new file mode 100644 index 0000000..9deef81 --- /dev/null +++ b/web/src/components/PowerDisplay.tsx @@ -0,0 +1,39 @@ +import { getPowerColor } from "../utils/powerUtils"; + +interface PowerDisplayProps { + value: number | undefined; + label?: string; + unit?: string; + decimals?: number; + style?: React.CSSProperties; + invertColors?: boolean; + showLabel?: boolean; +} + +export function PowerDisplay({ + value, + label, + unit = "kW", + decimals = 2, + style, + invertColors = false, + showLabel = false, +}: PowerDisplayProps) { + const displayValue = + value !== undefined ? `${value.toFixed(decimals)} ${unit}` : "N/A"; + const color = getPowerColor(value, invertColors); + + return ( +
+ {showLabel && label &&
{label}
} +
+ {displayValue} +
+ {!showLabel && label && ( +
+ {label} +
+ )} +
+ ); +} diff --git a/web/src/components/SolarInfo.tsx b/web/src/components/SolarInfo.tsx new file mode 100644 index 0000000..8cd5985 --- /dev/null +++ b/web/src/components/SolarInfo.tsx @@ -0,0 +1,53 @@ +interface SolarInfoProps { + solarAngle?: number; + sunrise?: string; + sunset?: string; + style?: React.CSSProperties; +} + +export function SolarInfo({ + solarAngle, + sunrise, + sunset, + style, +}: SolarInfoProps) { + const formatTime = (dateString?: string) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }; + + const getSolarAngleColor = (angle?: number) => { + if (angle === undefined) return "#666"; + if (angle < 0) return "#444"; // Below horizon + if (angle < 15) return "#ff9800"; // Low angle + if (angle < 45) return "#ffc107"; // Medium angle + return "#4caf50"; // High angle + }; + + return ( +
+
+
+
Solar Angle
+
+ {solarAngle !== undefined ? `${solarAngle.toFixed(1)}°` : "N/A"} +
+
+
+
🌅 Sunrise
+
{formatTime(sunrise)}
+
+
+
🌇 Sunset
+
{formatTime(sunset)}
+
+
+
+ ); +} diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..61eec0d --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -0,0 +1,17 @@ +interface StatusBadgeProps { + isActive: boolean; + activeLabel: string; + inactiveLabel: string; +} + +export function StatusBadge({ + isActive, + activeLabel, + inactiveLabel, +}: StatusBadgeProps) { + return ( +
+ {isActive ? activeLabel : inactiveLabel} +
+ ); +} diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..60830c8 --- /dev/null +++ b/web/src/hooks/useWebSocket.ts @@ -0,0 +1,131 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { HealthResponse, StatusResponse, WebSocketMessage } from "../types/api"; + +interface UseWebSocketReturn { + health: HealthResponse | null; + status: StatusResponse | null; + loading: boolean; + error: string | null; + wsConnected: boolean; +} + +export function useWebSocket(): UseWebSocketReturn { + const [health, setHealth] = useState(null); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [wsConnected, setWsConnected] = useState(false); + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const isConnectingRef = useRef(false); + + const connectWebSocket = useCallback(() => { + // Prevent duplicate connections + if ( + isConnectingRef.current || + (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) + ) { + console.log("Already connecting or connected, skipping..."); + return; + } + + isConnectingRef.current = true; + + // Clear any existing reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + // Close existing connection if any + if (wsRef.current) { + wsRef.current.close(); + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/api/ws`; + + console.log("Connecting to WebSocket:", wsUrl); + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log("WebSocket connected"); + setWsConnected(true); + setError(null); + setLoading(false); + reconnectAttemptsRef.current = 0; + isConnectingRef.current = false; + }; + + ws.onmessage = (event) => { + try { + const data: WebSocketMessage = JSON.parse(event.data); + + if (data.type === "status_update") { + setHealth(data.health); + setStatus(data.status); + setError(null); + } + } catch (err) { + console.error("Failed to parse WebSocket message:", err); + setError("Failed to parse server data"); + } + }; + + ws.onerror = (event) => { + console.error("WebSocket error:", event); + setError("WebSocket connection error"); + setWsConnected(false); + isConnectingRef.current = false; + }; + + ws.onclose = (event) => { + console.log("WebSocket closed:", event.code, event.reason); + setWsConnected(false); + wsRef.current = null; + isConnectingRef.current = false; + + // Attempt to reconnect with exponential backoff + reconnectAttemptsRef.current += 1; + const delay = Math.min( + 1000 * Math.pow(2, reconnectAttemptsRef.current), + 30000, + ); + + console.log( + `Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`, + ); + + reconnectTimeoutRef.current = setTimeout(() => { + connectWebSocket(); + }, delay); + }; + }, []); + + useEffect(() => { + connectWebSocket(); + + // Cleanup on unmount + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + health, + status, + loading, + error, + wsConnected, + }; +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 0000000..28da340 --- /dev/null +++ b/web/src/types/api.ts @@ -0,0 +1,59 @@ +export interface SchedulerStatus { + is_running: boolean; + miners_count: number; + has_market_data: boolean; + price_limit: number; + network: string; +} + +export interface HealthResponse { + status: string; + timestamp: string; + version: string; + scheduler: SchedulerStatus; + system: { + uptime: string; + goroutines: number; + }; + ems: { + current_pv_power: number; + ess_power: number; + ess_soc: number; + grid_sensor_status: number; + grid_sensor_active_power: number; + plant_active_power: number; + }; + sun: { + solar_angle: number; + sunrise: string; + sunset: string; + }; +} + +export interface StatusResponse { + scheduler_status: { + is_running: boolean; + miners_count: number; + has_market_data: boolean; + }; + miners: { + count: number; + list: Array<{ + ip: string; + status: string; + }>; + }; + price_data: { + has_document: boolean; + current_avg_price?: number; + current?: number; + limit?: number; + }; + timestamp: string; +} + +export interface WebSocketMessage { + type: string; + health: HealthResponse; + status: StatusResponse; +} diff --git a/web/src/utils/powerUtils.ts b/web/src/utils/powerUtils.ts new file mode 100644 index 0000000..a979dd4 --- /dev/null +++ b/web/src/utils/powerUtils.ts @@ -0,0 +1,13 @@ +// Utility function to determine power value color +export function getPowerColor( + value: number | undefined, + invertColors = false, +): string { + if (value === undefined) return "#f1f5f9"; + if (value === 0) return "#f1f5f9"; + + if (invertColors) { + return value < 0 ? "#ea580c" : "#16a34a"; + } + return value > 0 ? "#ea580c" : "#16a34a"; +} From 876b81ed7a95c26b49e5292a34af2123c1e45fef Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Thu, 1 Jan 2026 12:40:47 +0200 Subject: [PATCH 2/3] feat: add EVDC charger info --- scheduler/server.go | 4 ++++ sigenergy/info.go | 7 +++++++ sigenergy/modbus_client.go | 9 +++++++++ web/src/App.tsx | 10 ++++++++++ web/src/types/api.ts | 2 ++ 5 files changed, 32 insertions(+) diff --git a/scheduler/server.go b/scheduler/server.go index f847334..ff6431d 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -63,6 +63,8 @@ type EMSHealth struct { GridSensorStatus uint16 `json:"grid_sensor_status"` GridSensorActivePower float64 `json:"grid_sensor_active_power"` PlantActivePower float64 `json:"plant_active_power"` + DCChargerOutputPower float64 `json:"dc_charger_output_power"` + DCChargerVehicleSOC float64 `json:"dc_charger_vehicle_soc"` } // SunInfo represents solar position and timing information @@ -388,6 +390,8 @@ func (hs *WebServer) buildStatusData() map[string]any { GridSensorStatus: info.GridSensorStatus, GridSensorActivePower: info.GridSensorActivePower, PlantActivePower: info.PlantActivePower, + DCChargerOutputPower: info.DCChargerOutputPower, + DCChargerVehicleSOC: info.DCChargerVehicleSOC, } } diff --git a/sigenergy/info.go b/sigenergy/info.go index d942d62..3480201 100644 --- a/sigenergy/info.go +++ b/sigenergy/info.go @@ -91,6 +91,13 @@ func ShowPlantInfo(plantModbusAddress string) error { fmt.Printf(" Available Min Reactive Power: %.3f kVar\n", info.AvailableMinReactivePower) fmt.Println() + // DC Charger Information + fmt.Println("DC CHARGER") + fmt.Println("--------------------------------------------------") + fmt.Printf(" Output Power: %.3f kW\n", info.DCChargerOutputPower) + fmt.Printf(" Vehicle SOC: %.1f %%\n", info.DCChargerVehicleSOC) + fmt.Println() + // Alarms if info.GeneralAlarm1 != 0 || info.GeneralAlarm2 != 0 || info.GeneralAlarm3 != 0 || info.GeneralAlarm4 != 0 { fmt.Println("ALARMS") diff --git a/sigenergy/modbus_client.go b/sigenergy/modbus_client.go index 2915614..f56a52b 100644 --- a/sigenergy/modbus_client.go +++ b/sigenergy/modbus_client.go @@ -154,6 +154,8 @@ type PlantRunningInfo struct { ESSChargeOffSOC float64 // % ESSDischargeOffSOC float64 // % ESSSOH float64 // % + DCChargerOutputPower float64 // kW + DCChargerVehicleSOC float64 // % } // ReadPlantRunningInfo reads plant running information (slave address 247) @@ -209,6 +211,13 @@ func (c *SigenModbusClient) ReadPlantRunningInfo() (*PlantRunningInfo, error) { info.ESSSOH = float64(bytesToU16(data2[8:10])) / 10.0 } + // Read DC Charger data (31502-31504) + data3, err := c.client.ReadInputRegisters(31502, 3) + if err == nil { + info.DCChargerOutputPower = float64(bytesToS32(data3[0:4])) / 1000.0 + info.DCChargerVehicleSOC = float64(bytesToU16(data3[4:6])) / 10.0 + } + return info, nil } diff --git a/web/src/App.tsx b/web/src/App.tsx index 2eea04f..56bbb60 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -159,6 +159,16 @@ function App() { style={{ position: "absolute", top: "194px", left: "223px" }} /> + + Date: Thu, 1 Jan 2026 13:21:03 +0200 Subject: [PATCH 3/3] feat: add mpc decisions list --- meteo/utils.go | 24 ++- meteo/utils_test.go | 31 ++++ mpc/example/main.go | 2 + mpc/mpc.go | 19 +++ scheduler/mpc.go | 196 +++++++++++++----------- scheduler/scheduler.go | 19 +++ scheduler/server.go | 82 +++++++++- web/src/App.css | 144 ++++++++++++++++++ web/src/App.tsx | 4 + web/src/components/MPCDecisions.tsx | 224 ++++++++++++++++++++++++++++ web/src/types/api.ts | 19 +++ 11 files changed, 654 insertions(+), 110 deletions(-) create mode 100644 web/src/components/MPCDecisions.tsx diff --git a/meteo/utils.go b/meteo/utils.go index ce8b572..7cfeea8 100644 --- a/meteo/utils.go +++ b/meteo/utils.go @@ -1,6 +1,7 @@ package meteo import ( + "strings" "time" ) @@ -180,32 +181,27 @@ func (ts *ForecastTimeStep) GetSymbolCode() *WeatherSymbol { // IsDay checks if the weather symbol indicates daytime conditions func (ws WeatherSymbol) IsDay() bool { - str := string(ws) - return len(str) >= 4 && str[len(str)-4:] == "_day" + return strings.HasSuffix(string(ws), "_day") } // IsNight checks if the weather symbol indicates nighttime conditions func (ws WeatherSymbol) IsNight() bool { - str := string(ws) - return len(str) >= 6 && str[len(str)-6:] == "_night" + return strings.HasSuffix(string(ws), "_night") } // IsPolarTwilight checks if the weather symbol indicates polar twilight conditions func (ws WeatherSymbol) IsPolarTwilight() bool { - str := string(ws) - return len(str) >= 14 && str[len(str)-14:] == "_polartwilight" + return strings.HasSuffix(string(ws), "_polartwilight") } // HasThunder checks if the weather symbol indicates thunder func (ws WeatherSymbol) HasThunder() bool { - str := string(ws) - // Check if contains "thunder" anywhere in the symbol - for i := 0; i <= len(str)-7; i++ { - if str[i:i+7] == "thunder" { - return true - } - } - return false + return strings.Contains(string(ws), "thunder") +} + +// HasSnow checks if the weather symbol indicates snow conditions +func (ws WeatherSymbol) HasSnow() bool { + return strings.Contains(string(ws), "snow") } // IntPtr is a helper function to get a pointer to an int value diff --git a/meteo/utils_test.go b/meteo/utils_test.go index 1639aac..2f2ff28 100644 --- a/meteo/utils_test.go +++ b/meteo/utils_test.go @@ -423,6 +423,37 @@ func TestWeatherSymbol_HasThunder(t *testing.T) { } } +func TestWeatherSymbol_HasSnow(t *testing.T) { + tests := []struct { + symbol WeatherSymbol + expected bool + }{ + {ClearSkyDay, false}, + {Rain, false}, + {LightSnow, true}, + {Snow, true}, + {HeavySnow, true}, + {LightSnowShowersDay, true}, + {SnowShowersNight, true}, + {HeavySnowShowersDay, true}, + {SnowAndThunder, true}, + {LightSnowAndThunder, true}, + {HeavySnowAndThunder, true}, + {LightSnowShowersAndThunderDay, true}, + {Cloudy, false}, + {Fog, false}, + } + + for _, tt := range tests { + t.Run(string(tt.symbol), func(t *testing.T) { + result := tt.symbol.HasSnow() + if result != tt.expected { + t.Errorf("Expected HasSnow() = %v for symbol %s", tt.expected, tt.symbol) + } + }) + } +} + func TestHelperFunctions(t *testing.T) { // Test IntPtr intVal := 42 diff --git a/mpc/example/main.go b/mpc/example/main.go index ae9e9ac..288eaa2 100644 --- a/mpc/example/main.go +++ b/mpc/example/main.go @@ -52,8 +52,10 @@ func main() { loadForecast = 2.5 // Higher during active hours } + futureTime := time.Now().Add(time.Duration(i) * time.Hour) forecast[i] = mpc.TimeSlot{ Hour: i, + Timestamp: futureTime.Unix(), ImportPrice: importPrice, ExportPrice: exportPrice, SolarForecast: solarForecast, diff --git a/mpc/mpc.go b/mpc/mpc.go index 82dc923..d1389f2 100644 --- a/mpc/mpc.go +++ b/mpc/mpc.go @@ -20,21 +20,32 @@ type SystemConfig struct { // TimeSlot represents one hour of operation 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 + CloudCoverage float64 // % cloud coverage (0-100) + WeatherSymbol string // weather condition symbol } // ControlDecision represents the optimal control for one time slot type ControlDecision struct { Hour int + Timestamp int64 // Unix timestamp when this time slot begins BatteryCharge float64 // kW (positive = charging) 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 + // 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 + CloudCoverage float64 // % cloud coverage (0-100) + WeatherSymbol string // weather condition symbol } // MPCController implements Model Predictive Control @@ -114,6 +125,13 @@ func (mpc *MPCController) Optimize(forecast []TimeSlot) []ControlDecision { dp[t+1][newSOCIdx].decision = dec dp[t+1][newSOCIdx].decision.BatterySOC = newSOC dp[t+1][newSOCIdx].decision.Profit = profit + dp[t+1][newSOCIdx].decision.Timestamp = slot.Timestamp + dp[t+1][newSOCIdx].decision.ImportPrice = slot.ImportPrice + dp[t+1][newSOCIdx].decision.ExportPrice = slot.ExportPrice + dp[t+1][newSOCIdx].decision.SolarForecast = slot.SolarForecast + dp[t+1][newSOCIdx].decision.LoadForecast = slot.LoadForecast + dp[t+1][newSOCIdx].decision.CloudCoverage = slot.CloudCoverage + dp[t+1][newSOCIdx].decision.WeatherSymbol = slot.WeatherSymbol dp[t+1][newSOCIdx].prevSOC = socIdx } } @@ -179,6 +197,7 @@ func (mpc *MPCController) generateFeasibleDecisions(currentSOC float64, slot Tim for _, action := range batteryActions { dec := ControlDecision{ Hour: slot.Hour, + Timestamp: slot.Timestamp, BatteryCharge: action.charge, BatteryDischarge: action.discharge, } diff --git a/scheduler/mpc.go b/scheduler/mpc.go index 0aa8edb..ec81e14 100644 --- a/scheduler/mpc.go +++ b/scheduler/mpc.go @@ -24,17 +24,19 @@ func (s *MinerScheduler) runMPCOptimize(ctx context.Context) { return } - // Step 1: Read initial SOC from inverter - initialSOC, err := s.readInitialSOC(config) + // Step 1: Read plant running info from inverter + plantInfo, err := s.readPlantRunningInfo(config) if err != nil { - s.logger.Printf("Error reading initial SOC from inverter: %v", err) + s.logger.Printf("Error reading plant running info from inverter: %v", err) return } - s.logger.Printf("Initial battery SOC: %.1f%%", initialSOC*100) + // Extract initial SOC from plant info + initialSOC := plantInfo.ESSSOC / 100.0 // Convert from percentage (0-100) to fraction (0-1) + s.logger.Printf("Initial battery SOC: %.1f%%", plantInfo.ESSSOC) // Step 2: Get forecast data (prices, solar, load) - forecast, err := s.buildMPCForecast(ctx, config) + forecast, err := s.buildMPCForecast(ctx, config, plantInfo) if err != nil { s.logger.Printf("Error building MPC forecast: %v", err) return @@ -70,8 +72,18 @@ func (s *MinerScheduler) runMPCOptimize(ctx context.Context) { return } - // Step 5: Log optimization results - s.logMPCResults(forecast, decisions) + // Step 5: Save optimization results + s.mu.Lock() + s.mpcDecisions = decisions + s.mu.Unlock() + + // Log summary + s.logger.Printf("MPC optimization completed with %d decisions", len(decisions)) + totalProfit := 0.0 + for _, dec := range decisions { + totalProfit += dec.Profit + } + s.logger.Printf("Total expected profit over %d hours: €%.2f", len(decisions), totalProfit) // Step 6: Execute the first control decision if err := s.executeMPCDecision(&decisions[0], true); err != nil { @@ -82,29 +94,26 @@ func (s *MinerScheduler) runMPCOptimize(ctx context.Context) { s.logger.Printf("MPC optimization task completed successfully") } -// readInitialSOC reads the current State of Charge from the inverter -func (s *MinerScheduler) readInitialSOC(config *Config) (float64, error) { +// readPlantRunningInfo reads the plant running information from the inverter +func (s *MinerScheduler) readPlantRunningInfo(config *Config) (*sigenergy.PlantRunningInfo, error) { // Connect to Plant Modbus server client, err := sigenergy.NewTCPClient(config.PlantModbusAddress, sigenergy.PlantAddress) if err != nil { - return 0, fmt.Errorf("failed to connect to Plant Modbus: %w", err) + return nil, fmt.Errorf("failed to connect to Plant Modbus: %w", err) } defer client.Close() - // Read plant running info to get SOC + // Read plant running info plantInfo, err := client.ReadPlantRunningInfo() if err != nil { - return 0, fmt.Errorf("failed to read plant info: %w", err) + return nil, fmt.Errorf("failed to read plant info: %w", err) } - // Convert SOC from percentage (0-100) to fraction (0-1) - socFraction := plantInfo.ESSSOC / 100.0 - - return socFraction, nil + return plantInfo, nil } // buildMPCForecast builds the forecast data needed for MPC optimization -func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config) ([]mpc.TimeSlot, error) { +func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config, plantInfo *sigenergy.PlantRunningInfo) ([]mpc.TimeSlot, error) { now := time.Now() // Get electricity price forecast @@ -113,28 +122,41 @@ func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config) ( return nil, fmt.Errorf("failed to get price forecast: %w", err) } - // Get solar forecast - solarForecasts, err := s.getSolarForecast(config, now) + // Get weather forecast for weather data + weatherForecast, err := s.getOrFetchWeatherForecast(config) + if err != nil { + s.logger.Printf("Warning: failed to get weather forecast: %v", err) + 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 solarForecasts = make(map[int]float64) + weatherData = make(map[int]WeatherData) } // Build time slots var timeSlots []mpc.TimeSlot for hour, prices := range priceForecasts { solar := solarForecasts[hour] + weather := weatherData[hour] // Estimate load forecast (miners only, based on price and solar availability) loadForecast := s.estimateLoadForecast(prices.Import, config.PriceLimit/1000, solar, config) + futureTime := now.Add(time.Duration(hour) * time.Hour) timeSlots = append(timeSlots, mpc.TimeSlot{ Hour: 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 SolarForecast: solar, LoadForecast: loadForecast, + CloudCoverage: weather.CloudCoverage, + WeatherSymbol: weather.WeatherSymbol, }) } @@ -150,6 +172,12 @@ func (s *MinerScheduler) buildMPCForecast(ctx context.Context, config *Config) ( return timeSlots, nil } +// WeatherData represents weather information for a specific hour +type WeatherData struct { + CloudCoverage float64 // % cloud coverage (0-100) + WeatherSymbol string // weather condition symbol +} + // PricePoint represents import and export prices for a specific hour type PricePoint struct { Import float64 // EUR/MWh @@ -192,27 +220,32 @@ func (s *MinerScheduler) getPriceForecast(ctx context.Context, now time.Time) (m } // getSolarForecast gets solar power forecast from weather data -func (s *MinerScheduler) getSolarForecast(config *Config, now time.Time) (map[int]float64, error) { - // Get weather forecast - weatherForecast, err := s.getOrFetchWeatherForecast(config) - if err != nil { - return nil, fmt.Errorf("failed to get weather forecast: %w", err) +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 { + return nil, nil, fmt.Errorf("invalid weather forecast data") } - if weatherForecast == nil || weatherForecast.Properties == nil { - return nil, fmt.Errorf("invalid weather forecast data") + // Get current PV power to detect if panels are already covered by snow + currentPVPower := 0.0 + if plantInfo != nil { + currentPVPower = plantInfo.PhotovoltaicPower } // Convert weather to solar forecast solarForecast := make(map[int]float64) + weatherData := make(map[int]WeatherData) for i := range 24 { futureTime := now.Add(time.Duration(i) * time.Hour) - solarPower := s.estimateSolarPowerFromWeather(weatherForecast, futureTime, config.MaxSolarPower) + solarPower, cloudCoverage, weatherSymbol := s.estimateSolarPowerFromWeather(weatherForecast, futureTime, config.MaxSolarPower, currentPVPower) solarForecast[i] = solarPower + weatherData[i] = WeatherData{ + CloudCoverage: cloudCoverage, + WeatherSymbol: weatherSymbol, + } } - return solarForecast, nil + return solarForecast, weatherData, nil } // getOrFetchWeatherForecast gets weather forecast from cache or fetches new one @@ -242,9 +275,12 @@ func (s *MinerScheduler) getOrFetchWeatherForecast(config *Config) (*meteo.METJS } // estimateSolarPowerFromWeather estimates solar power output from weather data -func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONForecast, targetTime time.Time, peakPower float64) float64 { +func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONForecast, targetTime time.Time, peakPower float64, currentPVPower float64) (float64, float64, string) { + cloudCoverage := 0.0 + weatherSymbol := "" + if forecast.Properties == nil || len(forecast.Properties.Timeseries) == 0 { - return 0 + return 0, cloudCoverage, weatherSymbol } // Find closest time step @@ -263,11 +299,21 @@ func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONFo } if closestStep == nil || closestStep.Data == nil || closestStep.Data.Instant == nil || closestStep.Data.Instant.Details == nil { - return 0 + return 0, cloudCoverage, weatherSymbol } details := closestStep.Data.Instant.Details + // Get cloud coverage + if details.CloudAreaFraction != nil { + cloudCoverage = *details.CloudAreaFraction + } + + // Get weather symbol + if symbol := closestStep.GetSymbolCode(); symbol != nil { + weatherSymbol = string(*symbol) + } + // Get location from config config := s.GetConfig() lat := config.Latitude @@ -280,7 +326,7 @@ func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONFo // Check if we're between sunrise and sunset if targetTime.Before(sunrise) || targetTime.After(sunset) { - return 0 // No sun available + return 0, cloudCoverage, weatherSymbol // No sun available } // Get solar position to calculate altitude angle @@ -292,7 +338,24 @@ func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONFo // Use sine of altitude as a factor (0 at horizon, 1 at zenith) solarAngleFactor := math.Sin(altitude) if solarAngleFactor < 0 { - solarAngleFactor = 0 // Below horizon + return 0, cloudCoverage, weatherSymbol + } + + // Check for snow conditions - PV panels covered by snow produce zero power + if symbol := closestStep.GetSymbolCode(); symbol != nil { + if symbol.HasSnow() { + s.logger.Printf("Snow detected in weather forecast at %s, setting solar power to zero", targetTime.Format(time.RFC3339)) + return 0, cloudCoverage, weatherSymbol + } + } + + // Check if panels are already covered by snow: + // If current PV power is zero but we expect power based on sun angle, panels might be covered + expectedPower := peakPower * solarAngleFactor * 0.5 // Rough estimate with some clouds + if currentPVPower < 0.1 && expectedPower > 1.0 && time.Until(targetTime).Hours() < 1 { + // Current power is essentially zero but we expect power - likely snow covered + s.logger.Printf("Current PV power is zero (%.2f kW) but forecast expects %.2f kW - panels may be snow covered", currentPVPower, expectedPower) + return 0, cloudCoverage, weatherSymbol } // Cloud factor (0-1, where 1 = no clouds) @@ -305,27 +368,30 @@ func (s *MinerScheduler) estimateSolarPowerFromWeather(forecast *meteo.METJSONFo // Estimate solar power solarPower := peakPower * solarAngleFactor * cloudFactor - return solarPower + return solarPower, cloudCoverage, weatherSymbol } // estimateLoadForecast estimates power load based on price and available power // Follows the same logic as manageMiners: miners wake up in Eco mode when price <= limit, // but only if there's enough power budget (when PV power control is enabled) +// When miners are not running, they still consume standby power func (s *MinerScheduler) estimateLoadForecast(hourlyPrice float64, priceLimit float64, solarForecast float64, config *Config) float64 { // Convert hourlyPrice from EUR/MWh to EUR/kWh for comparison with priceLimit hourlyPricePerKWh := hourlyPrice / 1000.0 - // Miners are only ON if price is below or equal the limit - if hourlyPricePerKWh > priceLimit { - return 0.0 - } - // Get discovered miners minersList := s.GetDiscoveredMiners() if len(minersList) == 0 { return 0.0 } + // Miners are only ON if price is below or equal the limit + // Otherwise they consume standby power + if hourlyPricePerKWh > priceLimit { + // All miners are in standby mode + return float64(len(minersList)) * config.MinerPowerStandby + } + // Check if PV power control is enabled usePowerControl := config.UsePVPowerControl if !usePowerControl { @@ -350,59 +416,13 @@ func (s *MinerScheduler) estimateLoadForecast(hourlyPrice float64, priceLimit fl maxMinersCanRun := int(effectiveLimit / minerPowerEco) actualMinersRunning := min(maxMinersCanRun, len(minersList)) + minersInStandby := len(minersList) - actualMinersRunning - totalMinerPower := float64(actualMinersRunning) * minerPowerEco + // Total power = running miners in Eco mode + standby miners in standby mode + totalMinerPower := float64(actualMinersRunning)*minerPowerEco + float64(minersInStandby)*config.MinerPowerStandby return totalMinerPower } -// logMPCResults logs the optimization results -func (s *MinerScheduler) logMPCResults(forecast []mpc.TimeSlot, decisions []mpc.ControlDecision) { - s.logger.Printf("MPC Optimization Results:") - s.logger.Printf("Hour | Solar | Load | Import | Export | Battery | Grid | SOC | Profit") - s.logger.Printf("-----|-------|-------|---------|---------|-----------------|--------------|--------|-------") - - totalProfit := 0.0 - for i, dec := range decisions { - slot := forecast[i] - - gridAction := "idle" - gridPower := 0.0 - if dec.GridImport > 0.1 { - gridAction = "import" - gridPower = dec.GridImport - } else if dec.GridExport > 0.1 { - gridAction = "export" - gridPower = dec.GridExport - } - - battAction := "idle" - battPower := 0.0 - if dec.BatteryCharge > 0.1 { - battAction = "charge" - battPower = dec.BatteryCharge - } else if dec.BatteryDischarge > 0.1 { - battAction = "discharge" - battPower = dec.BatteryDischarge - } - - totalProfit += dec.Profit - - s.logger.Printf("%4d | %5.1f | %5.1f | €%.4f | €%.4f | %15s | %12s | %5.1f%% | €%.3f", - dec.Hour, - slot.SolarForecast, - slot.LoadForecast, - slot.ImportPrice, - slot.ExportPrice, - fmt.Sprintf("%s: %.1f", battAction, battPower), - fmt.Sprintf("%s: %.1f", gridAction, gridPower), - dec.BatterySOC*100, - dec.Profit, - ) - } - - s.logger.Printf("Total expected profit over %d hours: €%.2f", len(decisions), totalProfit) -} - // executeMPCDecision executes the first MPC control decision func (s *MinerScheduler) executeMPCDecision(decision *mpc.ControlDecision, dryRun bool) error { if dryRun { diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 749dd51..8c82803 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -10,6 +10,7 @@ import ( "github.com/devskill-org/ems/entsoe" "github.com/devskill-org/ems/miners" + "github.com/devskill-org/ems/mpc" _ "github.com/lib/pq" ) @@ -28,6 +29,9 @@ type MinerScheduler struct { // Weather forecast cache weatherCache WeatherForecastCache + // MPC optimization results + mpcDecisions []mpc.ControlDecision + // Web server webServer *WebServer @@ -293,6 +297,21 @@ func (s *MinerScheduler) GetStatus() SchedulerStatus { } } +// GetMPCDecisions returns a copy of the stored MPC decisions +func (s *MinerScheduler) GetMPCDecisions() []mpc.ControlDecision { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.mpcDecisions == nil { + return nil + } + + // Return a copy + decisionsCopy := make([]mpc.ControlDecision, len(s.mpcDecisions)) + copy(decisionsCopy, s.mpcDecisions) + return decisionsCopy +} + // SchedulerStatus represents the current status of the scheduler type SchedulerStatus struct { IsRunning bool `json:"is_running"` diff --git a/scheduler/server.go b/scheduler/server.go index ff6431d..70063ae 100644 --- a/scheduler/server.go +++ b/scheduler/server.go @@ -38,14 +38,34 @@ type StatusResponse struct { // SchedulerHealth represents scheduler-specific health information type SchedulerHealth struct { - IsRunning bool `json:"is_running"` - MinersCount int `json:"miners_count"` - LastCheck *time.Time `json:"last_check,omitempty"` - HasMarketData bool `json:"has_market_data"` - LastDocumentTime *time.Time `json:"last_document_time,omitempty"` - PriceLimit float64 `json:"price_limit"` - Network string `json:"network"` - CheckPriceInterval string `json:"check_price_interval"` + IsRunning bool `json:"is_running"` + MinersCount int `json:"miners_count"` + LastCheck *time.Time `json:"last_check,omitempty"` + HasMarketData bool `json:"has_market_data"` + LastDocumentTime *time.Time `json:"last_document_time,omitempty"` + PriceLimit float64 `json:"price_limit"` + Network string `json:"network"` + CheckPriceInterval string `json:"check_price_interval"` + MPCDecisions []MPCDecisionInfo `json:"mpc_decisions,omitempty"` +} + +// MPCDecisionInfo represents MPC optimization decision information for API +type MPCDecisionInfo struct { + Hour int `json:"hour"` + Timestamp int64 `json:"timestamp"` + BatteryCharge float64 `json:"battery_charge"` + BatteryDischarge float64 `json:"battery_discharge"` + GridImport float64 `json:"grid_import"` + GridExport float64 `json:"grid_export"` + BatterySOC float64 `json:"battery_soc"` + Profit float64 `json:"profit"` + // Forecast data used for this decision + ImportPrice float64 `json:"import_price"` + ExportPrice float64 `json:"export_price"` + SolarForecast float64 `json:"solar_forecast"` + LoadForecast float64 `json:"load_forecast"` + CloudCoverage float64 `json:"cloud_coverage"` + WeatherSymbol string `json:"weather_symbol"` } // SystemHealth represents system-level health information @@ -166,6 +186,28 @@ func (hs *WebServer) healthHandler(w http.ResponseWriter, r *http.Request) { status := hs.scheduler.GetStatus() + // Get MPC decisions and convert to API format + mpcDecisions := hs.scheduler.GetMPCDecisions() + mpcDecisionsInfo := make([]MPCDecisionInfo, 0, len(mpcDecisions)) + for _, dec := range mpcDecisions { + mpcDecisionsInfo = append(mpcDecisionsInfo, MPCDecisionInfo{ + Hour: dec.Hour, + Timestamp: dec.Timestamp, + BatteryCharge: dec.BatteryCharge, + BatteryDischarge: dec.BatteryDischarge, + GridImport: dec.GridImport, + GridExport: dec.GridExport, + BatterySOC: dec.BatterySOC, + Profit: dec.Profit, + ImportPrice: dec.ImportPrice, + ExportPrice: dec.ExportPrice, + SolarForecast: dec.SolarForecast, + LoadForecast: dec.LoadForecast, + CloudCoverage: dec.CloudCoverage, + WeatherSymbol: dec.WeatherSymbol, + }) + } + response := StatusResponse{ Status: "healthy", Timestamp: time.Now().UTC().Format(time.RFC3339), @@ -176,6 +218,7 @@ func (hs *WebServer) healthHandler(w http.ResponseWriter, r *http.Request) { HasMarketData: status.HasMarketData, PriceLimit: hs.scheduler.GetConfig().PriceLimit, Network: hs.scheduler.GetConfig().Network, + MPCDecisions: mpcDecisionsInfo, }, System: SystemHealth{ Uptime: formatUptime(time.Since(hs.startTime)), @@ -364,6 +407,28 @@ func (hs *WebServer) buildStatusData() map[string]any { overallStatus = "degraded" } + // Get MPC decisions and convert to API format + mpcDecisions := hs.scheduler.GetMPCDecisions() + mpcDecisionsInfo := make([]MPCDecisionInfo, 0, len(mpcDecisions)) + for _, dec := range mpcDecisions { + mpcDecisionsInfo = append(mpcDecisionsInfo, MPCDecisionInfo{ + Hour: dec.Hour, + Timestamp: dec.Timestamp, + BatteryCharge: dec.BatteryCharge, + BatteryDischarge: dec.BatteryDischarge, + GridImport: dec.GridImport, + GridExport: dec.GridExport, + BatterySOC: dec.BatterySOC, + Profit: dec.Profit, + ImportPrice: dec.ImportPrice, + ExportPrice: dec.ExportPrice, + SolarForecast: dec.SolarForecast, + LoadForecast: dec.LoadForecast, + CloudCoverage: dec.CloudCoverage, + WeatherSymbol: dec.WeatherSymbol, + }) + } + health := StatusResponse{ Status: overallStatus, Timestamp: time.Now().UTC().Format(time.RFC3339), @@ -374,6 +439,7 @@ func (hs *WebServer) buildStatusData() map[string]any { HasMarketData: status.HasMarketData, PriceLimit: hs.scheduler.GetConfig().PriceLimit, Network: hs.scheduler.GetConfig().Network, + MPCDecisions: mpcDecisionsInfo, }, System: SystemHealth{ Uptime: formatUptime(time.Since(hs.startTime)), diff --git a/web/src/App.css b/web/src/App.css index 10d3e0f..b297d17 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -346,6 +346,132 @@ body { font-family: "Courier New", monospace; } +/* MPC Decisions Styles */ +.mpc-summary { + display: flex; + gap: 2rem; + margin-bottom: 1.5rem; + padding: 1rem; + background-color: rgba(51, 65, 85, 0.5); + border-radius: 6px; + flex-wrap: wrap; +} + +.mpc-summary-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mpc-summary-label { + font-size: 0.875rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +.mpc-summary-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); +} + +.mpc-table-container { + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--color-border); +} + +.mpc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.mpc-table thead { + background-color: rgba(51, 65, 85, 0.7); +} + +.mpc-table th { + padding: 0.75rem 1rem; + text-align: left; + font-weight: 600; + color: var(--color-text); + border-bottom: 2px solid var(--color-border); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.mpc-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + color: var(--color-text); +} + +.mpc-table tbody tr { + transition: background-color 0.2s; +} + +.mpc-table tbody tr:hover { + background-color: rgba(51, 65, 85, 0.3); +} + +.mpc-table tbody tr:last-child td { + border-bottom: none; +} + +/* Align numeric columns to the right */ +.mpc-table td:nth-child(4), +.mpc-table td:nth-child(5), +.mpc-table td:nth-child(6), +.mpc-table td:nth-child(7), +.mpc-table td:nth-child(8), +.mpc-table td:nth-child(9) { + text-align: right; +} + +/* Prevent wrapping in forecast and profit columns */ +.mpc-table th:nth-child(5), +.mpc-table th:nth-child(6), +.mpc-table th:nth-child(7), +.mpc-table th:nth-child(8), +.mpc-table th:nth-child(9), +.mpc-table td:nth-child(5), +.mpc-table td:nth-child(6), +.mpc-table td:nth-child(7), +.mpc-table td:nth-child(8), +.mpc-table td:nth-child(9) { + white-space: nowrap; +} + +/* Forecast data header styling */ +.mpc-table thead tr:first-child th { + vertical-align: middle; +} + +.mpc-table thead tr:nth-child(2) th { + padding-top: 0.5rem; + padding-bottom: 0.75rem; + font-size: 0.7rem; +} + +.action-charge, +.action-import { + color: var(--color-warning); + font-weight: 600; +} + +.action-discharge, +.action-export { + color: var(--color-success); + font-weight: 600; +} + +.action-idle { + color: var(--color-text-secondary); + font-style: italic; +} + @media (max-width: 768px) { .header { flex-direction: column; @@ -389,4 +515,22 @@ body { .pv-power-value { font-size: 2rem; } + + .mpc-summary { + flex-direction: column; + gap: 1rem; + } + + .mpc-table-container { + overflow-x: scroll; + } + + .mpc-table { + font-size: 0.75rem; + } + + .mpc-table th, + .mpc-table td { + padding: 0.5rem; + } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 56bbb60..ba50b5a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { InfoItem } from "./components/InfoItem"; import { StatusBadge } from "./components/StatusBadge"; import { PowerDisplay } from "./components/PowerDisplay"; import { SolarInfo } from "./components/SolarInfo"; +import { MPCDecisions } from "./components/MPCDecisions"; import { useWebSocket } from "./hooks/useWebSocket"; function App() { @@ -134,6 +135,7 @@ function App() { + +

System Information

diff --git a/web/src/components/MPCDecisions.tsx b/web/src/components/MPCDecisions.tsx new file mode 100644 index 0000000..22964f4 --- /dev/null +++ b/web/src/components/MPCDecisions.tsx @@ -0,0 +1,224 @@ +import { MPCDecisionInfo } from "../types/api"; +import "../App.css"; + +// Helper function to format timestamp +const formatTimestamp = (timestamp: number): string => { + // Check if timestamp is valid (not 0, undefined, or null) + if (!timestamp || timestamp === 0) { + return "N/A"; + } + + const date = new Date(timestamp * 1000); + + // Check if date is valid + if (isNaN(date.getTime())) { + return "Invalid Date"; + } + + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +// Helper function to convert weather symbol to emoji +const getWeatherIcon = (symbol: string): string => { + if (!symbol) return "❓"; + + const s = symbol.toLowerCase(); + + // Thunder conditions + if (s.includes("thunder")) { + if (s.includes("snow")) return "⛈️❄️"; + if (s.includes("sleet")) return "⛈️🌧️"; + return "⛈️"; + } + + // Snow conditions + if (s.includes("snow")) { + if (s.includes("heavy")) return "❄️❄️"; + if (s.includes("light")) return "🌨️"; + return "❄️"; + } + + // Sleet conditions + if (s.includes("sleet")) { + if (s.includes("heavy")) return "🌧️❄️"; + return "🌨️"; + } + + // Rain conditions + if (s.includes("rain")) { + if (s.includes("heavy")) return "🌧️🌧️"; + if (s.includes("light")) return "🌦️"; + return "🌧️"; + } + + // Fog + if (s.includes("fog")) return "🌫️"; + + // Cloud conditions + if (s.includes("cloudy")) return "☁️"; + if (s.includes("partlycloudy")) { + if (s.includes("_night")) return "☁️🌙"; + return "⛅"; + } + + // Clear/Fair conditions + if (s.includes("clearsky") || s.includes("fair")) { + if (s.includes("_night") || s.includes("polartwilight")) return "🌙"; + return "☀️"; + } + + return "❓"; +}; + +interface MPCDecisionsProps { + decisions?: MPCDecisionInfo[]; +} + +export function MPCDecisions({ decisions }: MPCDecisionsProps) { + if (!decisions || decisions.length === 0) { + return ( +
+

MPC Optimization

+
+

No optimization data available yet.

+
+
+ ); + } + + // Calculate total profit + const totalProfit = decisions.reduce((sum, dec) => sum + dec.profit, 0); + + // Helper function to determine battery action + const getBatteryAction = (decision: MPCDecisionInfo) => { + if (decision.battery_charge > 0.1) { + return { action: "charge", power: decision.battery_charge }; + } else if (decision.battery_discharge > 0.1) { + return { action: "discharge", power: decision.battery_discharge }; + } + return { action: "idle", power: 0 }; + }; + + // Helper function to determine grid action + const getGridAction = (decision: MPCDecisionInfo) => { + if (decision.grid_import > 0.1) { + return { action: "import", power: decision.grid_import }; + } else if (decision.grid_export > 0.1) { + return { action: "export", power: decision.grid_export }; + } + return { action: "idle", power: 0 }; + }; + + // Helper function to get action color class + const getActionClass = (action: string) => { + switch (action) { + case "charge": + case "import": + return "action-import"; + case "discharge": + case "export": + return "action-export"; + default: + return "action-idle"; + } + }; + + return ( +
+

MPC Optimization Results

+
+
+ Decisions: + {decisions.length} hours +
+
+ Total Expected Profit: + = 0 ? "value-success" : "value-error"}`} + > + €{totalProfit.toFixed(2)} + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + {decisions.map((decision) => { + const batteryAction = getBatteryAction(decision); + const gridAction = getGridAction(decision); + + return ( + + + + + + + + + + + + + + ); + })} + +
TimeBattery ActionGrid ActionSOC + Forecast Data + Profit
Import (€/MWh)Export (€/MWh)Solar (kW)Load (kW)Cloud (%)Weather
{formatTimestamp(decision.timestamp)} + + {batteryAction.action} + {batteryAction.power > 0 && + `: ${batteryAction.power.toFixed(1)} kW`} + + + + {gridAction.action} + {gridAction.power > 0 && + `: ${gridAction.power.toFixed(1)} kW`} + + {(decision.battery_soc * 100).toFixed(1)}%{(decision.import_price * 1000).toFixed(2)}{(decision.export_price * 1000).toFixed(2)}{decision.solar_forecast.toFixed(1)}{decision.load_forecast.toFixed(1)}{decision.cloud_coverage.toFixed(0)} + {getWeatherIcon(decision.weather_symbol)} + = 0 ? "value-success" : "value-error" + } + > + €{decision.profit.toFixed(3)} +
+
+
+ ); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 8c2d4ec..f52f727 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,9 +1,28 @@ +export interface MPCDecisionInfo { + hour: number; + timestamp: number; + battery_charge: number; + battery_discharge: number; + grid_import: number; + grid_export: number; + battery_soc: number; + profit: number; + // Forecast data used for this decision + import_price: number; + export_price: number; + solar_forecast: number; + load_forecast: number; + cloud_coverage: number; + weather_symbol: string; +} + export interface SchedulerStatus { is_running: boolean; miners_count: number; has_market_data: boolean; price_limit: number; network: string; + mpc_decisions?: MPCDecisionInfo[]; } export interface HealthResponse {