From 5af02a20f4ca40552b88500ac724fe49ee4fc61f Mon Sep 17 00:00:00 2001 From: Aleksei Larkov Date: Wed, 4 Feb 2026 23:09:41 +0200 Subject: [PATCH] feat: add demo mode --- web/.gitignore | 1 + web/README.md | 46 +++- web/package.json | 5 +- web/src/App.css | 21 ++ web/src/App.tsx | 37 +++ web/src/components/DemoInfo.css | 193 +++++++++++++ web/src/components/DemoInfo.tsx | 41 +++ web/src/components/MPCDecisions.tsx | 80 +++++- web/src/components/MetricsSummary.tsx | 70 ++++- web/src/hooks/useWebSocket.ts | 54 +++- web/src/utils/mockData.ts | 374 ++++++++++++++++++++++++++ web/src/vite-env.d.ts | 2 + web/vite.config.ts | 37 ++- 13 files changed, 913 insertions(+), 48 deletions(-) create mode 100644 web/src/components/DemoInfo.css create mode 100644 web/src/components/DemoInfo.tsx create mode 100644 web/src/utils/mockData.ts diff --git a/web/.gitignore b/web/.gitignore index 1b22cc7..47e8551 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,5 +1,6 @@ node_modules dist +dist-demo build .vite *.log diff --git a/web/README.md b/web/README.md index dbd9aa2..6aa9966 100644 --- a/web/README.md +++ b/web/README.md @@ -36,6 +36,22 @@ npm run dev The application will be available at `http://localhost:3000`. API requests will be proxied to the backend at `http://localhost:8080`. +### Demo Mode + +For development or demonstration purposes without a backend server, you can run the application in demo mode. This mode uses realistic mock data that simulates all backend functionality: + +```bash +npm run dev:demo +``` + +In demo mode: +- No backend connection is required +- Realistic simulated data updates every 10 seconds +- Solar power, battery, grid, and device data reflect realistic daily patterns +- Electricity prices vary based on time of day +- MPC decisions are generated for the next 24 hours +- All UI components work exactly as they would with a real backend + ## Production Build Build the application for production: @@ -46,6 +62,27 @@ npm run build The built files will be in the `dist` directory, which the Go backend serves at the root path. +### Demo Build + +Build a standalone demo version that doesn't require a backend: + +```bash +npm run build:demo +``` + +The demo build will be in the `dist-demo` directory. You can preview it with: + +```bash +npm run preview:demo +``` + +The demo build is useful for: +- Demonstrations and presentations +- UI/UX testing without backend setup +- Showcasing the application to stakeholders +- Development when backend is unavailable +- Static hosting scenarios + ## Project Structure ``` @@ -115,10 +152,11 @@ The web application consumes the following API endpoints: The Vite configuration includes: - Development server on port 3000 -- API proxy to backend on port 8080 -- WebSocket proxy for real-time updates -- Production build output to `dist/` +- API proxy to backend on port 8080 (disabled in demo mode) +- WebSocket proxy for real-time updates (mock data in demo mode) +- Production build output to `dist/` (or `dist-demo/` for demo builds) - Source maps enabled +- Conditional compilation for demo mode via `__DEMO_MODE__` flag ## Customization @@ -211,6 +249,8 @@ Historical data can be visualized with charts showing: - Enable React DevTools for component inspection - Check console for WebSocket connection status - Use network tab to debug API issues +- Use demo mode for frontend-only development +- Mock data generator can be customized in `src/utils/mockData.ts` ## License diff --git a/web/package.json b/web/package.json index 206f3ba..dca5b6b 100644 --- a/web/package.json +++ b/web/package.json @@ -6,8 +6,11 @@ "type": "module", "scripts": { "dev": "vite", + "dev:demo": "vite --mode demo", "build": "tsc && vite build", - "preview": "vite preview" + "build:demo": "tsc && vite build --mode demo", + "preview": "vite preview", + "preview:demo": "vite preview --outDir dist-demo" }, "dependencies": { "react": "^18.2.0", diff --git a/web/src/App.css b/web/src/App.css index f45858b..118639e 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -90,6 +90,27 @@ body { border: 1px solid var(--color-warning); } +.demo-info-trigger { + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius); + font-weight: 600; + font-size: 0.875rem; + background-color: rgba(37, 99, 235, 0.2); + color: var(--color-primary); + border: 1px solid var(--color-primary); + cursor: pointer; + transition: all 0.2s; +} + +.demo-info-trigger:hover { + background-color: rgba(37, 99, 235, 0.3); + transform: scale(1.05); +} + +.demo-info-trigger:active { + transform: scale(0.98); +} + .main { flex: 1; padding: 2rem; diff --git a/web/src/App.tsx b/web/src/App.tsx index c570f53..93b13f0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,10 +6,27 @@ import { PowerDisplay } from "./components/PowerDisplay"; import { SolarInfo } from "./components/SolarInfo"; import { MPCDecisions } from "./components/MPCDecisions"; import { MetricsSummary } from "./components/MetricsSummary"; +import { DemoInfo } from "./components/DemoInfo"; import { useWebSocket } from "./hooks/useWebSocket"; +import { useState, useEffect } from "react"; + +// Check if we're in demo mode +const isDemoMode = typeof __DEMO_MODE__ !== 'undefined' && __DEMO_MODE__; function App() { const { health, status, loading, error, wsConnected } = useWebSocket(); + const [showDemoInfo, setShowDemoInfo] = useState(false); + + // Show demo info automatically on first load in demo mode + useEffect(() => { + if (isDemoMode) { + const hasSeenDemo = localStorage.getItem('ems-demo-info-seen'); + if (!hasSeenDemo) { + setShowDemoInfo(true); + localStorage.setItem('ems-demo-info-seen', 'true'); + } + } + }, []); if (loading) { return ( @@ -50,6 +67,22 @@ function App() { Energy Management System
+ {isDemoMode && ( + + )} + {isDemoMode && ( + + )} + + {isDemoMode && showDemoInfo && ( + setShowDemoInfo(false)} /> + )}
); } diff --git a/web/src/components/DemoInfo.css b/web/src/components/DemoInfo.css new file mode 100644 index 0000000..5688920 --- /dev/null +++ b/web/src/components/DemoInfo.css @@ -0,0 +1,193 @@ +.demo-info-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.demo-info-card { + background-color: var(--color-bg-card); + border-radius: var(--border-radius); + padding: 2rem; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + border: 2px solid var(--color-primary); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + position: relative; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.demo-info-close { + position: absolute; + top: 1rem; + right: 1rem; + background: transparent; + border: none; + color: var(--color-text-secondary); + font-size: 1.5rem; + cursor: pointer; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.demo-info-close:hover { + background-color: rgba(255, 255, 255, 0.1); + color: var(--color-text); +} + +.demo-info-card h2 { + font-size: 1.75rem; + font-weight: 700; + color: var(--color-text); + margin-bottom: 1rem; + border-bottom: 2px solid var(--color-primary); + padding-bottom: 0.75rem; +} + +.demo-info-description { + font-size: 1rem; + color: var(--color-text-secondary); + margin-bottom: 1.5rem; + line-height: 1.6; +} + +.demo-info-section { + margin-bottom: 1.5rem; +} + +.demo-info-section h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + margin-bottom: 0.75rem; +} + +.demo-info-section p { + font-size: 0.9375rem; + color: var(--color-text-secondary); + line-height: 1.6; + margin-bottom: 0.5rem; +} + +.demo-info-section ul { + list-style: none; + padding: 0; + margin: 0; +} + +.demo-info-section li { + font-size: 0.9375rem; + color: var(--color-text-secondary); + padding: 0.5rem 0; + padding-left: 0.5rem; + line-height: 1.5; + border-left: 3px solid var(--color-primary); + margin-bottom: 0.5rem; + background-color: rgba(37, 99, 235, 0.05); + border-radius: 0 4px 4px 0; +} + +.demo-info-note { + background-color: rgba(234, 88, 12, 0.1); + border: 1px solid var(--color-warning); + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; + font-size: 0.875rem; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.demo-info-note strong { + color: var(--color-warning); + font-weight: 600; +} + +.demo-info-note code { + background-color: rgba(0, 0, 0, 0.3); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-family: "Courier New", monospace; + font-size: 0.8125rem; + color: var(--color-text); +} + +.demo-info-button { + width: 100%; + background-color: var(--color-primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.demo-info-button:hover { + background-color: #1d4ed8; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +.demo-info-button:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +@media (max-width: 768px) { + .demo-info-card { + padding: 1.5rem; + width: 95%; + } + + .demo-info-card h2 { + font-size: 1.5rem; + } + + .demo-info-section h3 { + font-size: 1rem; + } + + .demo-info-section p, + .demo-info-section li { + font-size: 0.875rem; + } +} \ No newline at end of file diff --git a/web/src/components/DemoInfo.tsx b/web/src/components/DemoInfo.tsx new file mode 100644 index 0000000..f38416d --- /dev/null +++ b/web/src/components/DemoInfo.tsx @@ -0,0 +1,41 @@ +import "./DemoInfo.css"; + +interface DemoInfoProps { + onClose: () => void; +} + +export function DemoInfo({ onClose }: DemoInfoProps) { + return ( +
+
e.stopPropagation()}> + +

🎭 Demo Mode

+

+ You are viewing a demonstration version of the Energy Management + System with simulated data. +

+
+

What is Demo Mode?

+

+ This application is running without a backend server. All data you + see is generated in real-time to simulate realistic energy system + behavior. +

+
+
+

Data Updates:

+

+ Mock data refreshes every 10 seconds to simulate real-time updates. + Values change based on time of day to reflect realistic energy + patterns. +

+
+ +
+
+ ); +} diff --git a/web/src/components/MPCDecisions.tsx b/web/src/components/MPCDecisions.tsx index 0c5bf11..b32e78b 100644 --- a/web/src/components/MPCDecisions.tsx +++ b/web/src/components/MPCDecisions.tsx @@ -85,6 +85,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { const chartInstanceRef = useRef(null); const tooltipRef = useRef(null); const [showTable, setShowTable] = useState(false); + const [chartReady, setChartReady] = useState(false); // Store decisions and derived data in refs so chart callbacks can access them const decisionsRef = useRef([]); @@ -126,13 +127,23 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { // Callback ref to create chart when div is mounted const chartRef = useCallback((node: HTMLDivElement | null) => { - if (!node || chartInstanceRef.current) { + if (!node) { return; } - const opts: uPlot.Options = { - width: node.clientWidth, - height: 400, + // If chart already exists, just return + if (chartInstanceRef.current) { + return; + } + + // Add a small delay to ensure container is properly sized + setTimeout(() => { + // Ensure we have a valid width + const width = node.clientWidth || node.parentElement?.clientWidth || 800; + + const opts: uPlot.Options = { + width: width, + height: 400, title: "Price Trends & MPC Actions", tzDate: (ts) => new Date(ts * 1000), padding: [25, 20, 0, 0], @@ -331,7 +342,21 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { }, y: { range: (_u, min, max) => { - return [min, max + 100]; + // uPlot's auto-scaling only considers visible series, but our candlestick + // series are hidden. We need to manually calculate from all data. + const allHighs = highsRef.current || []; + const allLows = decisionsRef.current?.map(d => d.export_price * 1000) || []; + + if (allHighs.length === 0) { + return [min, max + 100]; + } + + const actualMax = Math.max(...allHighs); + const actualMin = Math.min(...allLows); + const range = actualMax - actualMin; + const padding = range * 0.1; // 10% padding + + return [actualMin - padding * 0.5, actualMax + padding]; }, }, }, @@ -424,27 +449,56 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { legend: { show: false, }, - }; + }; + + // Create chart with empty initial data + const emptyData: uPlot.AlignedData = [[], [], [], [], []]; + const chart = new uPlot(opts, emptyData, node); + chartInstanceRef.current = chart; - // Create chart with empty initial data - const emptyData: uPlot.AlignedData = [[], [], [], [], []]; - const chart = new uPlot(opts, emptyData, node); - chartInstanceRef.current = chart; + // Mark chart as ready + setChartReady(true); + + // Add ResizeObserver to handle container size changes + const resizeObserver = new ResizeObserver(() => { + if (chartInstanceRef.current && node.clientWidth > 0) { + const newWidth = node.clientWidth; + chartInstanceRef.current.setSize({ width: newWidth, height: 400 }); + } + }); + + resizeObserver.observe(node); + + // Store cleanup function + (node as any)._resizeObserver = resizeObserver; + }, 100); // 100ms delay to ensure DOM is ready }, [getBatteryAction, getGridAction, getActionClass]); // Cleanup on unmount useEffect(() => { return () => { + // Cleanup ResizeObserver if it exists + const chartNode = chartInstanceRef.current?.root; + if (chartNode && (chartNode as any)._resizeObserver) { + (chartNode as any)._resizeObserver.disconnect(); + } + if (chartInstanceRef.current) { chartInstanceRef.current.destroy(); chartInstanceRef.current = null; } + + setChartReady(false); }; }, []); // Update chart data when decisions change useEffect(() => { - if (!decisions || decisions.length === 0 || !chartInstanceRef.current) { + if (!decisions || decisions.length === 0) { + return; + } + + if (!chartInstanceRef.current || !chartReady) { return; } @@ -471,7 +525,7 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { // Update the existing chart with new data chartInstanceRef.current.setData(data); - }, [decisions]); + }, [decisions, chartReady]); if (!decisions || decisions.length === 0) { return ( @@ -517,6 +571,8 @@ export function MPCDecisions({ decisions }: MPCDecisionsProps) { borderRadius: "6px", padding: "1rem", border: "1px solid var(--color-border)", + minHeight: "450px", + width: "100%", }} />
(null); @@ -27,20 +64,29 @@ export function MetricsSummary() { try { setLoading(true); - // Calculate time range for selected date (calendar day - midnight to midnight) - const startTime = selectedDate.toISOString(); - const endDate = new Date(selectedDate); - endDate.setHours(23, 59, 59, 999); - const endTime = endDate.toISOString(); + // In demo mode, use mock data + if (isDemoMode) { + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 300)); + const mockData = generateMockMetricsSummary(selectedDate); + setMetricsSummary(mockData); + setError(null); + } else { + // Calculate time range for selected date (calendar day - midnight to midnight) + const startTime = selectedDate.toISOString(); + const endDate = new Date(selectedDate); + endDate.setHours(23, 59, 59, 999); + const endTime = endDate.toISOString(); - const url = `/api/metrics/summary?start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const url = `/api/metrics/summary?start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: MetricsSummaryType = await response.json(); + setMetricsSummary(data); + setError(null); } - const data: MetricsSummaryType = await response.json(); - setMetricsSummary(data); - setError(null); } catch (error) { console.error("Failed to fetch metrics summary:", error); setError("Failed to load actual costs data"); diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index 60830c8..6758549 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { HealthResponse, StatusResponse, WebSocketMessage } from "../types/api"; +import { createMockWebSocket } from "../utils/mockData"; interface UseWebSocketReturn { health: HealthResponse | null; @@ -9,6 +10,13 @@ interface UseWebSocketReturn { wsConnected: boolean; } +// Check if we're in demo mode +const isDemoMode = typeof __DEMO_MODE__ !== 'undefined' && __DEMO_MODE__; + +/** + * Hook for WebSocket connection to EMS backend + * Automatically switches to mock data in demo mode + */ export function useWebSocket(): UseWebSocketReturn { const [health, setHealth] = useState(null); const [status, setStatus] = useState(null); @@ -20,6 +28,7 @@ export function useWebSocket(): UseWebSocketReturn { const reconnectTimeoutRef = useRef(null); const reconnectAttemptsRef = useRef(0); const isConnectingRef = useRef(false); + const mockWsRef = useRef<{ close: () => void } | null>(null); const connectWebSocket = useCallback(() => { // Prevent duplicate connections @@ -106,18 +115,45 @@ export function useWebSocket(): UseWebSocketReturn { }; }, []); - useEffect(() => { - connectWebSocket(); + const connectDemoMode = useCallback(() => { + console.log("Demo mode: Using mock data"); + + // Simulate initial connection delay + const connectionTimeout = setTimeout(() => { + setLoading(false); + setWsConnected(true); + }, 500); + + // Create mock WebSocket connection with updates every 10 seconds + mockWsRef.current = createMockWebSocket((data) => { + setHealth(data.health); + setStatus(data.status); + }, 10000); - // Cleanup on unmount return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); + clearTimeout(connectionTimeout); + if (mockWsRef.current) { + mockWsRef.current.close(); } }; + }, []); + + useEffect(() => { + if (isDemoMode) { + return connectDemoMode(); + } else { + 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 }, []); @@ -128,4 +164,4 @@ export function useWebSocket(): UseWebSocketReturn { error, wsConnected, }; -} +} \ No newline at end of file diff --git a/web/src/utils/mockData.ts b/web/src/utils/mockData.ts new file mode 100644 index 0000000..a8594e9 --- /dev/null +++ b/web/src/utils/mockData.ts @@ -0,0 +1,374 @@ +import { + HealthResponse, + StatusResponse, + MPCDecisionInfo, + WebSocketMessage, +} from "../types/api"; + +/** + * Generates realistic mock data for the Energy Management System + */ + +// Helper function to get current hour +function getCurrentHour(): number { + return new Date().getHours(); +} + +// Helper function to generate timestamps (returns unix timestamp in seconds) +function getTimestamp(hoursOffset: number = 0): number { + const now = new Date(); + now.setHours(now.getHours() + hoursOffset); + now.setMinutes(0); + now.setSeconds(0); + now.setMilliseconds(0); + return Math.floor(now.getTime() / 1000); +} + +// Generate realistic solar power based on time of day (0-100 kW) +function generateSolarPower(hour: number): number { + if (hour < 6 || hour > 20) return 0; + if (hour < 8) return Math.random() * 15 + 5; // 5-20 kW + if (hour < 12) return Math.random() * 30 + 60; // 60-90 kW + if (hour < 16) return Math.random() * 20 + 70; // 70-90 kW + if (hour < 18) return Math.random() * 30 + 30; // 30-60 kW + return Math.random() * 10 + 5; // 5-15 kW +} + +// Generate realistic battery state +function generateBatteryState(hour: number) { + const baseSOC = hour < 12 ? 0.3 + hour * 0.03 : 0.9 - (hour - 12) * 0.02; + const soc = Math.max(0.1, Math.min(0.95, baseSOC + Math.random() * 0.1 - 0.05)); + + // Charging during day, discharging during evening (0-20 kW range) + let power = 0; + if (hour >= 9 && hour < 15) { + power = Math.random() * 10 + 5; // Charging: 5-15 kW + } else if (hour >= 18 && hour < 23) { + power = -(Math.random() * 15 + 5); // Discharging: -5 to -20 kW + } else { + power = Math.random() * 4 - 2; // Idle/small fluctuations: -2 to 2 kW + } + + return { soc, power }; +} + +// Generate realistic grid power (0-100 kW range) +function generateGridPower(hour: number): number { + if (hour >= 10 && hour < 16) { + // Exporting to grid during peak solar + return -(Math.random() * 50 + 20); // -20 to -70 kW + } else if (hour >= 18 && hour < 22) { + // Importing during evening peak + return Math.random() * 40 + 40; // 40-80 kW + } + return Math.random() * 20 - 10; // -10 to 10 kW +} + +// Generate realistic plant active power (consumption) (0-10 kW range) +function generatePlantPower(hour: number): number { + if (hour >= 8 && hour < 18) { + // Higher consumption during work hours + return Math.random() * 3 + 6; // 6-9 kW + } else if (hour >= 18 && hour < 23) { + // Evening peak + return Math.random() * 2 + 7; // 7-9 kW + } + // Night time lower consumption + return Math.random() * 2 + 2; // 2-4 kW +} + +// Generate EV charger state (0-30 kW range) +function generateEVChargerState(hour: number) { + const isCharging = hour >= 10 && hour < 16 && Math.random() > 0.3; + const vehicleSOC = isCharging + ? Math.min(0.95, 0.5 + (hour - 10) * 0.07) + : Math.random() * 0.3 + 0.2; + const power = isCharging ? Math.random() * 15 + 10 : 0; // 10-25 kW when charging + + return { power, soc: vehicleSOC }; +} + +// Generate electricity prices (€/kWh) - cached per hour to avoid frequent changes +function generatePrice(hour: number): number { + // Return cached price if available for this hour + if (cachedPriceByHour[hour] !== undefined) { + return cachedPriceByHour[hour]; + } + + // Generate price based on time of day (0.10 to 1 EUR per kWh) + let price: number; + if (hour >= 7 && hour < 9) { + price = Math.random() * 0.30 + 0.60; // Morning peak: 0.60-0.90 €/kWh + } else if (hour >= 18 && hour < 21) { + price = Math.random() * 0.40 + 0.60; // Evening peak: 0.60-1.00 €/kWh + } else if (hour >= 2 && hour < 6) { + price = Math.random() * 0.20 + 0.10; // Night low: 0.10-0.30 €/kWh + } else { + price = Math.random() * 0.30 + 0.30; // Normal: 0.30-0.60 €/kWh + } + + // Cache the generated price for this hour + cachedPriceByHour[hour] = price; + return price; +} + +// Generate weather symbol codes +function getWeatherSymbol(hour: number): string { + const symbols = [ + "clearsky_day", + "clearsky_night", + "fair_day", + "fair_night", + "partlycloudy_day", + "partlycloudy_night", + "cloudy", + "lightrain", + ]; + + if (hour < 6 || hour > 20) { + return Math.random() > 0.5 ? "clearsky_night" : "fair_night"; + } + return symbols[Math.floor(Math.random() * 5)]; +} + +// Cache for MPC decisions (regenerate every hour) +let cachedMPCDecisions: MPCDecisionInfo[] | null = null; +let mpcCacheTimestamp: number = 0; +const MPC_CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds + +// Cache for miners data (regenerate every hour) +let cachedMiners: Array<{ ip: string; status: string }> | null = null; +let minersCacheTimestamp: number = 0; +const MINERS_CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds + +// Cache for electricity prices (per hour) +const cachedPriceByHour: { [hour: number]: number } = {}; + +// Generate MPC decisions for the next 24 hours +function generateMPCDecisions(): MPCDecisionInfo[] { + // Return cached decisions if still valid + const now = Date.now(); + if (cachedMPCDecisions && (now - mpcCacheTimestamp) < MPC_CACHE_DURATION) { + return cachedMPCDecisions; + } + + // Generate fresh decisions + const decisions: MPCDecisionInfo[] = []; + const currentHour = getCurrentHour(); + + // Start with current realistic battery SOC (as decimal 0-1) + let currentSOC = Math.max(0.2, Math.min(0.8, 0.4 + Math.random() * 0.2)); + + for (let i = 0; i < 24; i++) { + const hour = (currentHour + i) % 24; + const timestamp = getTimestamp(i); + const solarForecast = generateSolarPower(hour); // Already in kW + const importPrice = generatePrice(hour); + const exportPrice = importPrice * 0.7; // Export typically lower than import + const loadForecast = generatePlantPower(hour); // Already in kW + const cloudCoverage = Math.random() * 100; + + // Battery strategy based on prices and solar + let batteryCharge = 0; + let batteryDischarge = 0; + + if (solarForecast > 50 && importPrice < 0.6 && currentSOC < 0.9) { + // Charge battery during cheap hours with solar + batteryCharge = Math.random() * 2 + 1; + currentSOC = Math.min(0.95, currentSOC + batteryCharge * 0.02); + } else if (importPrice > 0.8 && currentSOC > 0.2) { + // Discharge during expensive hours + batteryDischarge = Math.random() * 2 + 0.5; + currentSOC = Math.max(0.1, currentSOC - batteryDischarge * 0.02); + } else { + // Small fluctuations for idle state + currentSOC = Math.max(0.1, Math.min(0.95, currentSOC + Math.random() * 0.02 - 0.01)); + } + + // Grid strategy - only import OR export, never both + let gridImport = 0; + let gridExport = 0; + + // Determine net power flow: positive = surplus (export), negative = deficit (import) + const netPower = solarForecast - loadForecast + batteryDischarge - batteryCharge; + + if (netPower > 0 && exportPrice > 0.05) { + // Surplus power and reasonable export price - export to grid + gridExport = Math.min(netPower, Math.random() * 40 + 20); // 20-60 kW max + } else if (netPower < 0) { + // Power deficit - import from grid + gridImport = Math.abs(netPower) + Math.random() * 10; // Cover deficit + some margin + } + + const profit = (gridExport * exportPrice - gridImport * importPrice) / 1000; + + decisions.push({ + hour, + timestamp, + battery_charge: batteryCharge, + battery_discharge: batteryDischarge, + grid_import: gridImport, + grid_export: gridExport, + battery_soc: Math.max(0, Math.min(1, currentSOC)), + profit, + import_price: importPrice, + export_price: exportPrice, + solar_forecast: solarForecast, // Already in kW + load_forecast: loadForecast, + cloud_coverage: cloudCoverage, + weather_symbol: getWeatherSymbol(hour), + }); + } + + // Cache the new decisions + cachedMPCDecisions = decisions; + mpcCacheTimestamp = now; + + return decisions; +} + +// Generate mock miner data +function generateMiners() { + // Return cached miners if still valid + const now = Date.now(); + if (cachedMiners && (now - minersCacheTimestamp) < MINERS_CACHE_DURATION) { + return cachedMiners; + } + + // Generate fresh miners data + const minerCount = Math.floor(Math.random() * 3) + 2; // 2-4 miners + const miners = []; + const statuses = ["RUNNING", "STOPPED", "IDLE"]; + + for (let i = 0; i < minerCount; i++) { + miners.push({ + ip: `192.168.1.${100 + i}`, + status: statuses[Math.floor(Math.random() * statuses.length)], + }); + } + + // Cache the new miners data + cachedMiners = miners; + minersCacheTimestamp = now; + + return miners; +} + +// Calculate sunrise and sunset times +function getSunTimes() { + const now = new Date(); + const sunrise = new Date(now); + sunrise.setHours(6, 30 + Math.floor(Math.random() * 30), 0, 0); + + const sunset = new Date(now); + sunset.setHours(18, 30 + Math.floor(Math.random() * 60), 0, 0); + + return { + sunrise: sunrise.toISOString(), + sunset: sunset.toISOString(), + }; +} + +// Calculate solar angle (simplified) +function getSolarAngle(): number { + const hour = getCurrentHour(); + if (hour < 6 || hour > 20) return 0; + + // Peak at noon + const minutesFromNoon = Math.abs((hour - 12) * 60); + const maxAngle = 65; + return Math.max(0, maxAngle - (minutesFromNoon / 360) * maxAngle); +} + +/** + * Generate a complete mock WebSocket message + */ +export function generateMockWebSocketMessage(): WebSocketMessage { + const hour = getCurrentHour(); + const battery = generateBatteryState(hour); + const evCharger = generateEVChargerState(hour); + const sunTimes = getSunTimes(); + const miners = generateMiners(); + const currentPrice = generatePrice(hour); + const priceLimit = 75; + + const health: HealthResponse = { + status: "healthy", + timestamp: new Date().toISOString(), + version: "1.0.0-demo", + scheduler: { + is_running: true, + miners_count: miners.length, + has_market_data: true, + price_limit: priceLimit, + network: "Demo Network", + mpc_decisions: generateMPCDecisions(), + }, + system: { + uptime: "5d 12h 34m", + goroutines: 42, + }, + ems: { + current_pv_power: generateSolarPower(hour), + ess_power: battery.power, + ess_soc: battery.soc, + grid_sensor_status: 1, + grid_sensor_active_power: generateGridPower(hour), + plant_active_power: generatePlantPower(hour), + dc_charger_output_power: evCharger.power, + dc_charger_vehicle_soc: evCharger.soc, + }, + sun: { + solar_angle: getSolarAngle(), + sunrise: sunTimes.sunrise, + sunset: sunTimes.sunset, + }, + }; + + const status: StatusResponse = { + scheduler_status: { + is_running: true, + miners_count: miners.length, + has_market_data: true, + }, + miners: { + count: miners.length, + list: miners, + }, + price_data: { + has_document: true, + current_avg_price: currentPrice, + current: currentPrice, + limit: priceLimit, + }, + timestamp: new Date().toISOString(), + }; + + return { + type: "status_update", + health, + status, + }; +} + +/** + * Create a mock WebSocket-like interface that emits periodic updates + */ +export function createMockWebSocket( + onMessage: (data: WebSocketMessage) => void, + interval: number = 10000 +): { close: () => void } { + // Send initial message immediately + setTimeout(() => { + onMessage(generateMockWebSocketMessage()); + }, 100); + + // Set up periodic updates + const intervalId = setInterval(() => { + onMessage(generateMockWebSocketMessage()); + }, interval); + + return { + close: () => clearInterval(intervalId), + }; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts index 11f02fe..dd0b330 100644 --- a/web/src/vite-env.d.ts +++ b/web/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __DEMO_MODE__: boolean; \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index 22eb7ff..f8d082b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,14 +1,29 @@ import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; -export default defineConfig({ - server: { - port: 3000, - proxy: { - "/api": { - target: "http://localhost:8080", - changeOrigin: true, - ws: true, - }, +export default defineConfig(({ mode }) => { + const isDemo = mode === "demo"; + + return { + plugins: [react()], + base: isDemo ? "/demo/" : "/", + server: { + port: 3000, + proxy: isDemo + ? undefined + : { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + ws: true, + }, + }, + }, + define: { + __DEMO_MODE__: JSON.stringify(isDemo), + }, + build: { + outDir: isDemo ? "dist-demo" : "dist", }, - }, -}); + }; +}); \ No newline at end of file