diff --git a/web/package-lock.json b/web/package-lock.json index 4574f0a..fd70c26 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "uplot": "^1.6.32" }, "devDependencies": { "@types/react": "^18.2.43", @@ -1610,6 +1611,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/web/package.json b/web/package.json index 914f51b..206f3ba 100644 --- a/web/package.json +++ b/web/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "uplot": "^1.6.32" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/web/src/App.css b/web/src/App.css index 614e1de..f45858b 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -150,7 +150,7 @@ body { .value-success { font-weight: 600; color: var(--color-success); - font-size: 0.875rem; + font-size: 0.65rem; } .value-warning { @@ -162,7 +162,7 @@ body { .value-error { font-weight: 600; color: var(--color-error); - font-size: 0.875rem; + font-size: 0.65rem; } .miners-list { @@ -239,7 +239,7 @@ body { right: 0; bottom: 0; background-image: url("../assets/ems.png"); - background-size: cover; + background-size: contain; background-position: center; background-repeat: no-repeat; z-index: 0; @@ -252,6 +252,8 @@ body { justify-content: center; align-items: center; min-height: 250px; + width: 100%; + height: 100%; } .pv-power-display { @@ -392,7 +394,8 @@ body { .mpc-table { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: 0.7rem; + table-layout: fixed; } .mpc-table thead { @@ -400,18 +403,29 @@ body { } .mpc-table th { - padding: 0.75rem 1rem; + padding: 0.4rem 0.5rem; 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; + font-size: 0.65rem; + letter-spacing: 0.03em; +} + +.mpc-table th:first-child, +.mpc-table td:first-child { + width: 120px; +} + +.mpc-table th:not(:first-child), +.mpc-table td:not(:first-child) { + width: 25px; + text-align: center; } .mpc-table td { - padding: 0.75rem 1rem; + padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--color-border); color: var(--color-text); } @@ -428,29 +442,15 @@ body { border-bottom: none; } -/* 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; + padding-top: 0.3rem; + padding-bottom: 0.4rem; + font-size: 0.6rem; } .action-charge, @@ -513,19 +513,72 @@ body { } .devices-section { - min-height: 200px; + min-height: auto; + padding: 1rem; + } + + .devices-section::before { + /* Hide background image on mobile */ + display: none; } .devices-content { - min-height: 150px; + min-height: auto; + flex-direction: column; + gap: 1rem; + padding: 0; + position: static !important; + } + + /* Wrapper for power displays with mobile labels */ + .devices-content .power-display-wrapper { + position: static !important; + margin: 0 auto; + width: 100%; + max-width: 280px; + display: flex; + flex-direction: column; + align-items: center; + } + + /* Show label from data attribute on mobile */ + .devices-content .power-display-wrapper::before { + content: attr(data-mobile-label); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; + text-align: center; + } + + /* Convert absolute positioned PowerDisplay components to static flow on mobile */ + .devices-content .power-display-wrapper .pv-power-display { + position: static !important; + margin: 0 auto; + width: 100%; + padding: 1rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; } - .pv-power-display { - padding: 1.5rem 2rem; + .devices-content .solar-info { + position: static !important; + width: 100% !important; + max-width: 280px; + margin: 0 auto; } .pv-power-value { - font-size: 2rem; + font-size: 1.5rem; + } + + .pv-power-label { + font-size: 0.875rem; + margin-bottom: 0.5rem; + margin-top: 0 !important; } .mpc-summary { diff --git a/web/src/App.tsx b/web/src/App.tsx index 613c186..c570f53 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -129,48 +129,81 @@ function App() {

Devices

- +
+ +
- + > + +
- +
+ +
- +
+ +
- + > + + { return "Invalid Date"; } - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours().toString().padStart(2, "0"); + + return `${month}/${day} ${hour}`; }; // Helper function to convert weather symbol to emoji @@ -80,6 +82,340 @@ interface MPCDecisionsProps { } export function MPCDecisions({ decisions }: MPCDecisionsProps) { + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const tooltipRef = useRef(null); + const [showTable, setShowTable] = useState(false); + + useEffect(() => { + if (!decisions || decisions.length === 0 || !chartRef.current) { + return; + } + + // Prepare data for candlestick chart + const timestamps = decisions.map((d) => d.timestamp); + + // Convert prices from €/MWh to €/kWh for better visualization + const importPrices = decisions.map((d) => d.import_price * 1000); + const exportPrices = decisions.map((d) => d.export_price * 1000); + + // Calculate open, high, low, close for candlesticks + // Since export price is always lower than import price: + // Open = export price (low), Close = import price (high) + // High = import price, Low = export price + const opens = exportPrices; + const closes = importPrices; + const highs = importPrices; + const lows = exportPrices; + + const data: uPlot.AlignedData = [timestamps, opens, highs, lows, closes]; + + const opts: uPlot.Options = { + width: chartRef.current.clientWidth, + height: 400, + title: "Price Trends & MPC Actions", + tzDate: (ts) => new Date(ts * 1000), + padding: [25, 20, 0, 0], + plugins: [ + { + hooks: { + setCursor: (u) => { + const tooltip = tooltipRef.current; + if (!tooltip) return; + + const { left, top, idx } = u.cursor; + + if (idx == null || idx < 0 || idx >= decisions.length) { + tooltip.style.display = "none"; + return; + } + + const decision = decisions[idx]; + const batteryAction = getBatteryAction(decision); + const gridAction = getGridAction(decision); + + tooltip.innerHTML = ` +
+ ${formatTimestamp(decision.timestamp)} +
+
+
Battery:
+
+ ${batteryAction.power > 0 ? `${batteryAction.power.toFixed(1)} kW` : "Idle"} +
+ +
Grid:
+
+ ${gridAction.power > 0 ? `${gridAction.power.toFixed(1)} kW` : "Idle"} +
+ +
SOC:
+
${(decision.battery_soc * 100).toFixed(1)}%
+ +
Import:
+
${(decision.import_price * 1000).toFixed(2)} €/MWh
+ +
Export:
+
${(decision.export_price * 1000).toFixed(2)} €/MWh
+ +
Solar:
+
${decision.solar_forecast.toFixed(1)} kW
+ +
Load:
+
${decision.load_forecast.toFixed(1)} kW
+ +
Cloud:
+
${decision.cloud_coverage.toFixed(0)}%
+ +
Weather:
+
${getWeatherIcon(decision.weather_symbol)}
+ +
Profit:
+
+ €${decision.profit.toFixed(3)} +
+
+ `; + + tooltip.style.display = "block"; + + // Position tooltip + const chartRect = u.root.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + let tooltipLeft = left! + 10; + let tooltipTop = top! + 10; + + // Keep tooltip within chart bounds + if (tooltipLeft + tooltipRect.width > chartRect.width) { + tooltipLeft = left! - tooltipRect.width - 10; + } + + if (tooltipTop + tooltipRect.height > chartRect.height) { + tooltipTop = chartRect.height - tooltipRect.height - 10; + } + + tooltip.style.left = `${tooltipLeft}px`; + tooltip.style.top = `${tooltipTop}px`; + }, + draw: (u) => { + // Draw candlesticks with battery action colors and grid action indicators + const ctx = u.ctx; + if (!ctx) return; + + ctx.save(); + + const xdata = u.data[0]; + const open = u.data[1]; + const high = u.data[2]; + const low = u.data[3]; + const close = u.data[4]; + + const bodyMaxWidth = 16; + + // Draw candlesticks + for (let i = 0; i < decisions.length; i++) { + const xVal = xdata[i]; + const yOpen = open[i]; + const yHigh = high[i]; + const yLow = low[i]; + const yClose = close[i]; + + if ( + xVal == null || + yOpen == null || + yHigh == null || + yLow == null || + yClose == null + ) { + continue; + } + + const x = Math.round(u.valToPos(xVal, "x", true) ?? 0); + const yO = Math.round(u.valToPos(yOpen, "y", true) ?? 0); + const yH = Math.round(u.valToPos(yHigh, "y", true) ?? 0); + const yL = Math.round(u.valToPos(yLow, "y", true) ?? 0); + const yC = Math.round(u.valToPos(yClose, "y", true) ?? 0); + + const width = Math.min( + bodyMaxWidth, + Math.max(3, u.bbox.width / xdata.length - 2), + ); + + // Draw wick (high-low line) + ctx.strokeStyle = "#f1f5f9"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, yL); + ctx.lineTo(x, yH); + ctx.stroke(); + + // Draw body with battery action color + const bodyTop = Math.min(yO, yC); + const bodyHeight = Math.abs(yO - yC); + + const decision = decisions[i]; + const batteryAction = getBatteryAction(decision); + + if (batteryAction.action === "charge") { + ctx.fillStyle = "#ea580c"; // Orange for charge + } else if (batteryAction.action === "discharge") { + ctx.fillStyle = "#16a34a"; // Green for discharge + } else { + ctx.fillStyle = "#94a3b8"; // Gray for idle + } + + ctx.fillRect(x - width / 2, bodyTop, width, bodyHeight || 1); + + // Draw border + ctx.strokeStyle = "#f1f5f9"; + ctx.lineWidth = 1; + ctx.strokeRect(x - width / 2, bodyTop, width, bodyHeight || 1); + } + + // Draw grid action indicators + ctx.font = "bold 16px sans-serif"; + + for (let i = 0; i < decisions.length; i++) { + const decision = decisions[i]; + const gridAction = getGridAction(decision); + + const cx = u.valToPos(decision.timestamp, "x", true); + const cy = u.valToPos(highs[i], "y", true); + + if (cx && cy) { + // Draw grid action indicator with bigger arrows + if (gridAction.action === "export") { + ctx.fillStyle = "#16a34a"; + ctx.fillText("↑", cx + 6, cy - 10); + } else if (gridAction.action === "import") { + ctx.fillStyle = "#ea580c"; + ctx.fillText("↓", cx + 6, cy - 10); + } + } + } + + ctx.restore(); + }, + }, + }, + ], + scales: { + x: { + time: true, + range: (_u, min, max) => { + const timeRange = max - min; + const padding = timeRange * 0.02; // 2% padding on each side + return [min, max + padding]; + }, + }, + y: { + range: (_u, min, max) => { + return [min, max + 100]; + }, + }, + }, + axes: [ + { + stroke: "#94a3b8", + grid: { + show: true, + stroke: "#334155", + width: 1, + }, + ticks: { + stroke: "#334155", + }, + values: (_u, vals) => + vals.map((v) => { + const date = new Date(v * 1000); + return date + .toLocaleString("en-US", { + month: "numeric", + day: "numeric", + hour: "2-digit", + hour12: false, + }) + .replace(/,/, ""); + }), + }, + { + stroke: "#94a3b8", + label: "Price (€/MWh)", + labelSize: 30, + labelFont: "12px sans-serif", + size: 70, + gap: 10, + grid: { + show: true, + stroke: "#334155", + width: 1, + }, + ticks: { + stroke: "#334155", + }, + values: (_u, vals) => vals.map((v) => v.toFixed(2)), + }, + ], + series: [ + { + label: "Time", + }, + { + label: "Export (Open)", + stroke: "#f1f5f9", + width: 0, + points: { show: false }, + }, + { + label: "High (Import)", + stroke: "#f1f5f9", + width: 0, + points: { show: false }, + show: false, + }, + { + label: "Low (Export)", + stroke: "#f1f5f9", + width: 0, + points: { show: false }, + show: false, + }, + { + label: "Import (Close)", + stroke: "#f1f5f9", + width: 0, + points: { show: false }, + show: false, + }, + ], + cursor: { + points: { + show: false, + }, + drag: { + x: false, + y: false, + }, + sync: { + key: "mpc", + }, + }, + legend: { + show: false, + }, + }; + + const chart = new uPlot(opts, data, chartRef.current); + chartInstanceRef.current = chart; + + // Cleanup + return () => { + chart.destroy(); + chartInstanceRef.current = null; + }; + }, [decisions]); + if (!decisions || decisions.length === 0) { return (
@@ -94,15 +430,6 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { // Calculate total profit const totalProfit = decisions.reduce((sum, dec) => sum + dec.profit, 0); - // Find highest and lowest import/export prices - const importPrices = decisions.map((d) => d.import_price); - const exportPrices = decisions.map((d) => d.export_price); - - const highestImport = Math.max(...importPrices); - const lowestImport = Math.min(...importPrices); - const highestExport = Math.max(...exportPrices); - const lowestExport = Math.min(...exportPrices); - // Helper function to determine battery action const getBatteryAction = (decision: MPCDecisionInfo) => { if (decision.battery_charge > 0.1) { @@ -155,103 +482,226 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { -
- - - - - - - - - - - - - - - - - - - - - {decisions.map((decision) => { - const batteryAction = getBatteryAction(decision); - const gridAction = getGridAction(decision); + {/* Candlestick Chart */} +
+
+
+
+
+
+ Battery Discharge +
+
+
+ Battery Charge +
+
+
+ Battery Idle +
+
+ ↑ + Grid Export +
+
+ ↓ + Grid Import +
+
+
- 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`} - +
+ +
+ + {showTable && ( +
+ + + + + {decisions.map((decision) => ( + + ))} + + + + + + {decisions.map((decision) => { + const batteryAction = getBatteryAction(decision); + return ( + + ); + })} + + + + {decisions.map((decision) => { + const gridAction = getGridAction(decision); + return ( + + ); + })} + + + + {decisions.map((decision) => ( + - + + + + {decisions.map((decision) => ( + - - + + + {decisions.map((decision) => ( + + ))} + + + + {decisions.map((decision) => ( + - + + + {decisions.map((decision) => ( + + ))} + + + + {decisions.map((decision) => ( + - - - - - - - ); - })} - -
Metric + {formatTimestamp(decision.timestamp)} +
Battery Action (kW) + + {batteryAction.power > 0 + ? batteryAction.power.toFixed(1) + : ""} + +
Grid Action (kW) + + {gridAction.power > 0 + ? gridAction.power.toFixed(1) + : ""} + +
SOC (%) + {(decision.battery_soc * 100).toFixed(1)} - - {gridAction.action} - {gridAction.power > 0 && - `: ${gridAction.power.toFixed(1)} kW`} - + ))} +
Solar (kW) + {decision.solar_forecast.toFixed(1)} {(decision.battery_soc * 100).toFixed(1)}% - - {(decision.import_price * 1000).toFixed(2)} - + ))} +
Load (kW) + {decision.load_forecast.toFixed(1)} +
Cloud (%) + {decision.cloud_coverage.toFixed(0)} + ))} +
Weather + {getWeatherIcon(decision.weather_symbol)} +
Profit (€) = 0 ? "value-success" : "value-error" } > - {(decision.export_price * 1000).toFixed(2)} + {Math.abs(decision.profit).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)} -
-
+ ))} +
+
+ )}
); }