From e3fc4f4c53013b2893174976e86b8d0cb9209385 Mon Sep 17 00:00:00 2001 From: spicyneutrino <107169289+spicyneutrino@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:39:15 -0600 Subject: [PATCH 1/5] feat: websocket migration (clean history) --- core/power_system.py | 8 +- index.html | 27 +- main_complete_integration.py | 195 +++++- manhattan_sumo_manager.py | 98 ++- scenario_controller.py | 10 +- static/scenario-controls.js | 98 ++- static/scenario-director.js | 160 +++-- static/script.js | 1238 ++++++++++++++++++++++++++-------- 8 files changed, 1437 insertions(+), 397 deletions(-) diff --git a/core/power_system.py b/core/power_system.py index 5214e57..8318bc1 100644 --- a/core/power_system.py +++ b/core/power_system.py @@ -836,6 +836,11 @@ def _store_network_state(self): import json try: + # Guard: Check if db_manager and get_session are available + if not hasattr(db_manager, 'get_session') or not callable(getattr(db_manager, 'get_session', None)): + # Database not configured - skip storage (this is optional for simulation) + return + with db_manager.get_session() as session: state = NetworkState( simulation_time=int(self.network.snapshots[0].timestamp()), @@ -859,7 +864,8 @@ def _store_network_state(self): session.commit() except Exception as e: - logger.error(f"Failed to store network state: {e}") + # Don't spam logs - database storage is optional for demo + pass def _calculate_health_score(self) -> float: """Calculate overall system health score (0-100)""" diff --git a/index.html b/index.html index d1509af..9f9951a 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + @@ -19,8 +20,8 @@ - - + + @@ -531,18 +532,20 @@

- - - - - + + + + + + - - - - + + + + diff --git a/main_complete_integration.py b/main_complete_integration.py index 1ca70ec..8939d7a 100644 --- a/main_complete_integration.py +++ b/main_complete_integration.py @@ -14,6 +14,7 @@ from flask import Flask, render_template_string, jsonify, request from flask_cors import CORS +from flask_socketio import SocketIO, emit import json import threading import time @@ -43,7 +44,22 @@ def load_dotenv(*args, **kwargs): load_dotenv() app = Flask(__name__) +app.config['SECRET_KEY'] = 'secret!' CORS(app) +socketio = SocketIO(app, async_mode='threading', cors_allowed_origins="*") + +# System state - Defined early for access +from core.sumo_manager import SimulationScenario +system_state = { + 'running': True, + 'sumo_running': False, + 'simulation_speed': 1.0, + 'current_time': 0, + 'scenario': SimulationScenario.MIDDAY +} + +# Asynchronous vehicle spawn queue (prevents UI freezing) +vehicle_spawn_queue = [] # Initialize systems print("=" * 60) @@ -101,6 +117,32 @@ def load_dotenv(*args, **kwargs): print("Initializing V2G energy trading system...") v2g_manager = V2GManager(integrated_system, sumo_manager) +# Register WebSocket callback for V2G state changes +def v2g_websocket_callback(event_type, data): + """Emit V2G events via WebSocket""" + if event_type == 'restoration_complete': + # Calculate participating vehicles + vehicles = [] + for vid, session in v2g_manager.active_sessions.items(): + if session.substation_id == data['substation']: + vehicles.append({ + 'id': vid, + 'earnings': session.earnings, + 'energy_delivered': session.power_delivered_kwh + }) + + # Emit restoration complete event + socketio.emit('v2g_restoration_complete', { + 'substation': data['substation'], + 'energy_delivered': data['energy_delivered'], + 'revenue': data['total_revenue'], + 'vehicles': vehicles + }) + print(f"[WebSocket] Emitted v2g_restoration_complete for {data['substation']}") + +v2g_manager.register_notification_callback(v2g_websocket_callback) +print("V2G WebSocket notifications enabled") + sumo_manager.set_v2g_manager(v2g_manager) # Initialize Enhanced ML Engine with V2G integration @@ -130,7 +172,11 @@ def load_dotenv(*args, **kwargs): # Initialize OpenAI client (optional if key provided) OPENAI_API_KEY = os.getenv('OPENAI_API_KEY') -openai_client = OpenAI(api_key=OPENAI_API_KEY) if (OPENAI_API_KEY and OpenAI) else None +try: + openai_client = OpenAI(api_key=OPENAI_API_KEY) if (OPENAI_API_KEY and OpenAI) else None +except Exception as e: + print(f"OpenAI client initialization skipped: {e}") + openai_client = None # Initialize REALISTIC LOAD MODEL and SCENARIO CONTROLLER print("=" * 60) @@ -146,15 +192,94 @@ def load_dotenv(*args, **kwargs): load_model = RealisticLoadModel(integrated_system) print("Initializing scenario controller...") + + # Define WebSocket broadcast callback + # Define WebSocket broadcast callback - WORLD CLASS REAL-TIME UPDATES + def broadcast_state(scenario_status): + try: + # 1. Get Base Network State (Substations, Cables, etc.) + # This should be fast as it reads from internal memory structure + state = integrated_system.get_network_state() + + # 2. Add Scenario Controller Data (Time, Weather, Stats) + state['scenario'] = scenario_status + + # 3. Add SUMO Running Flag (needed for Start/Stop button state) + state['sumo_running'] = system_state.get('sumo_running', False) + + # 4. Add Real-Time Vehicle Data if SUMO is running + if system_state.get('sumo_running', False) and sumo_manager.running: + try: + # Use the complete vehicle visualization method that includes all data + # (battery_percent, soc, is_ev, is_charging, etc.) + vehicles = sumo_manager.get_vehicle_positions_for_visualization() + state['vehicles'] = vehicles + state['vehicle_count'] = len(vehicles) + + # Get pending vehicles (in insertion queue) + import traci + try: + # Get vehicles waiting to enter the network + pending_count = traci.simulation.getPendingVehicles().getIDCount() + except: + # Fallback if API doesn't work + pending_count = 0 + + # Add vehicle statistics (for charging count, etc.) + vehicle_stats = { + 'active_vehicles': len(vehicles), # Currently on road + 'pending_vehicles': pending_count, # Waiting in queue + 'total_configured': len(vehicles) + pending_count, # Total spawned + 'total_vehicles': len(vehicles), # Legacy field + 'ev_vehicles': sum(1 for v in vehicles if v.get('is_ev', False)), + 'gas_vehicles': sum(1 for v in vehicles if not v.get('is_ev', False)), + 'vehicles_charging': sum(1 for v in vehicles if v.get('is_charging', False)), + 'vehicles_low_battery': sum(1 for v in vehicles if v.get('is_ev', False) and v.get('battery_percent', 100) < 20), + 'vehicles_medium_battery': sum(1 for v in vehicles if v.get('is_ev', False) and 20 <= v.get('battery_percent', 100) < 50), + 'vehicles_high_battery': sum(1 for v in vehicles if v.get('is_ev', False) and v.get('battery_percent', 100) >= 50), + } + state['vehicle_stats'] = vehicle_stats + + except Exception as e: + print(f"Socket vehicle update error: {e}") + + # 4. Add V2G Data if initialized + if 'v2g_manager' in globals() and v2g_manager: + try: + # Get the full dash data - this is efficient as it uses internal state + v2g_data = v2g_manager.get_v2g_dashboard_data() + state['v2g'] = v2g_data + except Exception as e: + print(f"Socket V2G update error: {e}") + + # 5. Add AI Map Focus Data if available + if 'ai_map_focus_data' in globals() and ai_map_focus_data: + try: + state['ai_focus'] = { + 'has_update': True, + 'focus_data': ai_map_focus_data + } + except Exception as e: + print(f"Socket AI focus update error: {e}") + else: + state['ai_focus'] = {'has_update': False} + + # Emit unified system update event + socketio.emit('system_update', state) + + except Exception as e: + print(f"Broadcast error: {e}") + scenario_controller = ScenarioController( integrated_system=integrated_system, load_model=load_model, power_grid=power_grid, - sumo_manager=sumo_manager + sumo_manager=sumo_manager, + on_update_callback=broadcast_state ) # Start automatic monitoring - scenario_controller.start_auto_monitoring() + # scenario_controller.start_auto_monitoring() # DISABLED: Controlled by simulation_loop now # Add API endpoints integrate_scenario_controller(app, scenario_controller, load_model) @@ -206,14 +331,7 @@ def preload_edge_shapes(max_edges: int | None = None) -> int: return count return count -# System state -system_state = { - 'running': True, - 'sumo_running': False, - 'simulation_speed': 1.0, - 'current_time': 0, - 'scenario': SimulationScenario.MIDDAY -} + # EV Configuration current_ev_config = { @@ -267,6 +385,10 @@ def simulation_loop(): print(f"V2G State Update: {V2G_UPDATE}s (V2G session rate)") print("="*70 + "\n") + # BROADCAST OPTIMIZATION + BROADCAST_INTERVAL = 5 # Send 1 update per 5 physics steps + step_counter = 0 + while system_state['running']: try: step_start = time_module.perf_counter() @@ -293,6 +415,32 @@ def simulation_loop(): sumo_time = (time_module.perf_counter() - sumo_start) * 1000 perf_stats['sumo_step'].append(sumo_time) + # SOCKET BROADCAST: Frame Skipping Logic + step_counter += 1 + if step_counter % BROADCAST_INTERVAL == 0: + try: + status = scenario_controller.get_system_status() if scenario_controller else {"status": "Running"} + broadcast_state(status) + except Exception as e: + print(f"Broadcast loop error: {e}") + + # ASYNC VEHICLE SPAWNING: Process spawn queue in batches (max 5 per tick) + # This prevents UI freezing when bulk spawning vehicles + global vehicle_spawn_queue + if vehicle_spawn_queue: + batch_size = min(5, len(vehicle_spawn_queue)) + for _ in range(batch_size): + config = vehicle_spawn_queue.pop(0) + try: + sumo_manager.spawn_vehicles( + count=1, + ev_percentage=config['ev_percentage'], + battery_min_soc=config['battery_min_soc'], + battery_max_soc=config['battery_max_soc'] + ) + except Exception as e: + print(f"[QUEUE] Spawn error: {e}") + # REALISTIC: V2G updates every 60 seconds (vehicle-to-grid state changes) if system_state['current_time'] - last_v2g_update >= V2G_STEPS: v2g_manager.update_v2g_sessions() @@ -877,23 +1025,34 @@ def stop_sumo(): @app.route('/api/sumo/spawn', methods=['POST']) def spawn_vehicles(): - """Spawn additional vehicles""" + """Spawn additional vehicles (async queue)""" + global vehicle_spawn_queue + if not system_state['sumo_running']: return jsonify({'success': False, 'message': 'SUMO not running'}) + # Extract parameters from frontend request data = request.json or {} count = data.get('count', 5) ev_percentage = data.get('ev_percentage', 0.7) battery_min_soc = data.get('battery_min_soc', 0.2) battery_max_soc = data.get('battery_max_soc', 0.9) - spawned = sumo_manager.spawn_vehicles(count, ev_percentage, battery_min_soc, battery_max_soc) - + # Queue individual vehicles with frontend-specified configs + for i in range(count): + vehicle_spawn_queue.append({ + 'ev_percentage': ev_percentage, + 'battery_min_soc': battery_min_soc, + 'battery_max_soc': battery_max_soc + }) + + # Return immediately (202 Accepted) - vehicles will spawn in background return jsonify({ 'success': True, - 'spawned': spawned, - 'total_vehicles': sumo_manager.stats['total_vehicles'] - }) + 'message': f'{count} vehicles queued for spawning', + 'queued': len(vehicle_spawn_queue), + 'total_vehicles': sumo_manager.stats.get('total_vehicles', 0) + }), 202 @app.route('/api/sumo/scenario', methods=['POST']) def set_scenario(): @@ -2036,4 +2195,4 @@ def load_html_template(): print(" - Fail substations to see EV stations go offline") print("=" * 60) - app.run(debug=False, port=5000) + socketio.run(app, debug=False, port=5000, allow_unsafe_werkzeug=True) diff --git a/manhattan_sumo_manager.py b/manhattan_sumo_manager.py index f2a801b..e8611b6 100644 --- a/manhattan_sumo_manager.py +++ b/manhattan_sumo_manager.py @@ -88,17 +88,22 @@ def _find_nearest_charging_station(self, vehicle_id: str, current_edge: str) -> if not self.station_manager: return None - # Get vehicle position + # Get vehicle position and current edge try: x, y = traci.vehicle.getPosition(vehicle_id) vehicle_lon, vehicle_lat = traci.simulation.convertGeo(x, y) + vehicle_edge = traci.vehicle.getRoadID(vehicle_id) + + # Skip if on internal/junction edge + if vehicle_edge.startswith(':'): + return None except: return None best_station = None min_distance = float('inf') - # Check ALL stations and find the nearest one + # Check ALL stations and find the nearest REACHABLE one for station_id, station in self.station_manager.stations.items(): # Check if station is operational if not station['operational']: @@ -120,9 +125,39 @@ def _find_nearest_charging_station(self, vehicle_id: str, current_edge: str) -> station_info['lat'], station_info['lon'] ) - if dist < min_distance: - min_distance = dist - best_station = station_id + # CRITICAL: Reachability check - verify route exists + try: + # Get station edge from SUMO location + station_x, station_y = traci.simulation.convertGeo( + station_info['lon'], station_info['lat'], + fromGeo=True + ) + station_edges = traci.simulation.convertRoad( + station_x, station_y, + isGeo=False + ) + + if not station_edges or len(station_edges) == 0: + continue # No edge found for station + + station_edge = station_edges[0] + + # Verify route exists from vehicle to station + route_result = traci.simulation.findRoute(vehicle_edge, station_edge) + + # Check if route is valid (cost != -1 and has edges) + if not route_result or not route_result.edges or len(route_result.edges) == 0: + # No valid route - skip this station + continue + + # Route exists! Consider this station + if dist < min_distance: + min_distance = dist + best_station = station_id + + except Exception as e: + # Route verification failed - skip this station + continue return best_station @@ -467,15 +502,36 @@ def spawn_vehicles(self, count: int = 10, ev_percentage: float = 0.3, battery_mi attempts = 0 max_attempts = count * 10 # Allow many attempts to get exact count - # Get ALL valid edges from SUMO + # Get valid edges from SUMO - only those that allow passenger vehicles all_edges = traci.edge.getIDList() - valid_edges = [e for e in all_edges if not e.startswith(':') and traci.edge.getLaneNumber(e) > 0] + valid_edges = [] + + for edge_id in all_edges: + # Skip internal/junction edges + if edge_id.startswith(':'): + continue + + # Check if edge has lanes + if traci.edge.getLaneNumber(edge_id) == 0: + continue + + # CRITICAL: Check if edge allows passenger vehicles + try: + # Get allowed vehicle classes for the edge + allowed = traci.edge.getAllowed(edge_id) + # If empty, all vehicle types are allowed + # If not empty, check if 'passenger' is in the list + if not allowed or 'passenger' in allowed: + valid_edges.append(edge_id) + except: + # If we can't determine, assume it's valid + valid_edges.append(edge_id) if not valid_edges: print("ERROR: No valid edges found in SUMO network") return 0 - print(f"Spawning {count} vehicles using {len(valid_edges)} valid edges...") + print(f"Spawning {count} vehicles using {len(valid_edges)} passenger-accessible edges...") # Keep trying until we get the exact count while spawned < count and attempts < max_attempts: @@ -514,8 +570,9 @@ def spawn_vehicles(self, count: int = 10, ev_percentage: float = 0.3, battery_mi else: continue - # Try to find route - route_result = traci.simulation.findRoute(origin, destination) + # CRITICAL FIX: Use findRoute with vType to validate departure edge permissions + # This checks if the vehicle TYPE can actually depart from the origin edge + route_result = traci.simulation.findRoute(origin, destination, vType=vtype) if route_result and route_result.edges and len(route_result.edges) > 0: # Valid route found! @@ -524,13 +581,20 @@ def spawn_vehicles(self, count: int = 10, ev_percentage: float = 0.3, battery_mi # Add route traci.route.add(route_id, route_result.edges) - # Add vehicle - traci.vehicle.add( - vehicle_id, - route_id, - typeID=vtype, - depart="now" - ) + # Add vehicle with explicit departure checking + try: + traci.vehicle.add( + vehicle_id, + route_id, + typeID=vtype, + depart="now" + ) + except Exception as e: + # If departure fails, remove the edge from valid_edges + if origin in valid_edges: + valid_edges.remove(origin) + print(f" Departure failed on edge {origin}: {str(e)}, removed from valid edges") + continue # Set REALISTIC Manhattan speeds and COLLISION PREVENTION traci.vehicle.setMaxSpeed(vehicle_id, 13.9) # 50 km/h (31 mph) - realistic city speed diff --git a/scenario_controller.py b/scenario_controller.py index 1e5c9cb..a9e9e5c 100644 --- a/scenario_controller.py +++ b/scenario_controller.py @@ -99,11 +99,12 @@ class ScenarioController: World-class scenario controller for testing Manhattan power grid """ - def __init__(self, integrated_system, load_model, power_grid, sumo_manager=None): + def __init__(self, integrated_system, load_model, power_grid, sumo_manager=None, on_update_callback=None): self.integrated_system = integrated_system self.load_model = load_model self.power_grid = power_grid self.sumo_manager = sumo_manager + self.on_update_callback = on_update_callback # Scenario parameters - REALISTIC TIME SYSTEM self.current_time_seconds = 12 * 3600 # Time in seconds since midnight (0-86400) @@ -450,6 +451,13 @@ def _monitor_loop(self): self.load_model.set_time_of_day(hour_float) self._update_all_loads() + + # Broadcast update via WebSocket if callback exists + if self.on_update_callback: + try: + self.on_update_callback(self.get_system_status()) + except Exception as e: + print(f"Callback error: {e}") time.sleep(1) # Update every real second diff --git a/static/scenario-controls.js b/static/scenario-controls.js index cc089c0..3a16c88 100644 --- a/static/scenario-controls.js +++ b/static/scenario-controls.js @@ -533,17 +533,26 @@ class ScenarioControllerUI { } async runScenario(scenarioName) { + console.log(`πŸš€ Running scenario: ${scenarioName}`); try { let scenarioConfig = {}; + // Morning Rush Hour - 8:00 AM, 75Β°F, 95 vehicles (PEAK TRAFFIC) + // Evening Rush Hour - 6:00 PM, 80Β°F, 98 vehicles (HEAVIEST TRAFFIC) + // Normal Day - 12:00 PM, 72Β°F, 65 vehicles (MODERATE TRAFFIC) + // Heatwave Crisis - 3:00 PM, 98Β°F, 85 vehicles - EXTREME CONDITIONS! + // CATASTROPHIC HEAT - 2:00 PM, 115Β°F, 75 vehicles (REDUCED - heat avoidance) + // Late Night - 3:00 AM, 65Β°F, 15 vehicles (MINIMAL TRAFFIC) + // Define scenario configurations switch(scenarioName) { case 'morning_rush': scenarioConfig = { time: 8, temp: 75, - vehicles: 95, // PEAK RUSH - Heavy commuter traffic - description: 'πŸŒ… Morning Rush Hour - 8:00 AM, 75Β°F, 95 vehicles (PEAK TRAFFIC)' + vehicles: 95, // TARGET - actual may be lower due to routing + name: 'πŸŒ… Morning Rush Hour', + timeDesc: '8:00 AM' }; break; @@ -551,8 +560,9 @@ class ScenarioControllerUI { scenarioConfig = { time: 18, temp: 80, - vehicles: 98, // HIGHEST TRAFFIC - Commute home + errands + deliveries - description: 'πŸŒ† Evening Rush Hour - 6:00 PM, 80Β°F, 98 vehicles (HEAVIEST TRAFFIC)' + vehicles: 98, // TARGET - actual may be lower due to routing + name: 'πŸŒ† Evening Rush Hour', + timeDesc: '6:00 PM' }; break; @@ -560,8 +570,9 @@ class ScenarioControllerUI { scenarioConfig = { time: 12, temp: 72, - vehicles: 65, // MODERATE - Lunch traffic, less than rush hour - description: 'β˜€οΈ Normal Day - 12:00 PM, 72Β°F, 65 vehicles (MODERATE TRAFFIC)' + vehicles: 65, + name: 'β˜€οΈ Normal Day', + timeDesc: '12:00 PM' }; break; @@ -569,8 +580,9 @@ class ScenarioControllerUI { scenarioConfig = { time: 15, temp: 98, - vehicles: 85, // HIGH - Afternoon activity in extreme heat - description: 'πŸ”₯ Heatwave Crisis - 3:00 PM, 98Β°F, 85 vehicles - EXTREME CONDITIONS!' + vehicles: 85, + name: 'πŸ”₯ Heatwave Crisis', + timeDesc: '3:00 PM' }; break; @@ -578,8 +590,9 @@ class ScenarioControllerUI { scenarioConfig = { time: 14, temp: 115, - vehicles: 75, // REDUCED - Many avoid travel in catastrophic heat - description: '☒️ CATASTROPHIC HEAT - 2:00 PM, 115Β°F, 75 vehicles (REDUCED - heat avoidance)' + vehicles: 75, + name: '☒️ CATASTROPHIC HEAT', + timeDesc: '2:00 PM' }; break; @@ -587,8 +600,9 @@ class ScenarioControllerUI { scenarioConfig = { time: 3, temp: 65, - vehicles: 15, // MINIMAL - Only essential/night shift traffic - description: 'πŸŒ™ Late Night - 3:00 AM, 65Β°F, 15 vehicles (MINIMAL TRAFFIC)' + vehicles: 15, + name: 'πŸŒ™ Late Night', + timeDesc: '3:00 AM' }; break; @@ -617,13 +631,16 @@ class ScenarioControllerUI { // Set temperature await this.setTemperature(scenarioConfig.temp); - // Add vehicles using SUMO - await this.spawnVehicles(scenarioConfig.vehicles); + // Add vehicles using SUMO - this returns actual count + const actualCount = await this.spawnVehicles(scenarioConfig.vehicles); - console.log(`βœ“ Scenario started: ${scenarioConfig.description}`); + // Build description with ACTUAL spawned count + const description = `${scenarioConfig.name} - ${scenarioConfig.timeDesc}, ${scenarioConfig.temp}Β°F, ${actualCount || scenarioConfig.vehicles} vehicles`; + + console.log(`βœ“ Scenario started: ${description}`); - // Show notification - this.showNotification(scenarioConfig.description); + // Show final notification with actual count + this.showNotification(description); } catch (error) { console.error('Error running scenario:', error); @@ -937,15 +954,50 @@ class ScenarioControllerUI { } startStatusUpdates() { - // Update status every 3 seconds - this.autoUpdate = setInterval(() => { - this.updateStatus(); - }, 3000); - - // Initial update + console.log("Starting WebSocket status updates..."); + // Initialize Socket.IO + this.socket = io(); + + this.socket.on('connect', () => { + console.log('βœ“ Connected to Scenario WebSocket'); + }); + + this.socket.on('system_update', (data) => { + this.handleSystemUpdate(data); + }); + + // Initial fetch just to be safe setTimeout(() => this.updateStatus(), 500); } + handleSystemUpdate(data) { + if (!data) return; + + // 1. Update Scenario UI (Time, Weather, Substations) + if (data.scenario) { + // Update time if auto-advancing + if (data.scenario.auto_advance) { + // Update internal time + this.currentTime = data.scenario.time_hour + (data.scenario.time_minute/60); + + // Update UI elements without triggering events + const timeSlider = document.getElementById('time-slider'); + if (timeSlider && Math.abs(timeSlider.value - this.currentTime) > 0.1) { + timeSlider.value = this.currentTime; // This might be jumpy, maybe don't update slider constantly? + } + this.updateTimeDisplay(this.currentTime); + } + + // Update Substation Status Panel + this.updateMainSubstationDisplay(data.scenario.substations); + } + + // 2. Update Map and Network Visualization + if (window.updateNetworkFromData) { + window.updateNetworkFromData(data); + } + } + togglePanel() { const content = document.querySelector('.scenario-panel-content'); const btn = document.getElementById('toggle-scenario-panel'); diff --git a/static/scenario-director.js b/static/scenario-director.js index 98e0faf..547faf9 100644 --- a/static/scenario-director.js +++ b/static/scenario-director.js @@ -186,58 +186,126 @@ Status: ${restorationData.status}`; } /** - * Chatbot Monitoring Loop - Updates chatbot in real-time + * Chatbot Monitoring - Listen for V2G restoration events via WebSocket */ startChatbotMonitoring(substation) { - let lastProgress = 0; - let hasNotifiedRestoration = false; - let maxEnergyDelivered = 0; // Track maximum energy seen - let maxVehicleCount = 0; - - const monitorInterval = setInterval(async () => { - try { - // Get V2G status - const statusResp = await fetch('/api/v2g/status'); - const status = await statusResp.json(); - - const required = (status?.energy_required && status.energy_required[substation]) || 25; - const delivered = (status?.energy_delivered && status.energy_delivered[substation]) || 0; - const progress = Math.round((delivered / Math.max(1, required)) * 100); - const activeVehicles = Array.isArray(status?.active_vehicles) - ? status.active_vehicles.filter(v => v.substation === substation).length - : 0; - - // Track maximum values (in case they get cleared) - if (delivered > maxEnergyDelivered) { - maxEnergyDelivered = delivered; + console.log(`[SCENARIO] Starting WebSocket monitoring for ${substation}`); + + // Listen for v2g_restoration_complete event from WebSocket + if (window.socket) { + // Remove any existing listener for this substation + window.socket.off('v2g_restoration_complete'); + + // Add new listener + window.socket.on('v2g_restoration_complete', (data) => { + console.log('[SCENARIO] Received v2g_restoration_complete event:', data); + + if (data.substation === substation) { + console.log(`[SCENARIO] Restoration complete for ${substation}!`); + this.handleRestorationComplete(data); } - if (activeVehicles > maxVehicleCount) { - maxVehicleCount = activeVehicles; - } - - // Update chatbot every 20% progress change - if (progress >= lastProgress + 20 && progress < 100) { - lastProgress = progress; - - // Send update to chatbot - this.addDirectChatMessage(`⚑ V2G Progress: ${progress}% (${activeVehicles} vehicles discharging)`, 'progress'); + }); + + console.log(`[SCENARIO] WebSocket listener registered for ${substation} restoration`); + } else { + console.warn('[SCENARIO] WebSocket not available, restoration notifications disabled'); + } + } + + /** + * Handle V2G restoration completion + */ + handleRestorationComplete(data) { + const { substation, energy_delivered, revenue, vehicles } = data; + + console.log('[CHATBOT] πŸŽ‰ TRIGGERING RESTORATION NOTIFICATION!'); + + // Calculate totals + const vehicleCount = vehicles ? vehicles.length : 0; + const totalRevenue = revenue || 0; + const energyDelivered = energy_delivered || 0; + + console.log(`[CHATBOT] Vehicles: ${vehicleCount}, Revenue: $${totalRevenue}, Energy: ${energyDelivered} kWh`); + + // Send restoration notification to chatbot - ALWAYS SHOW + const restoreMsg = `πŸŽ‰ V2G RESTORATION COMPLETE!\n\n` + + `βœ… ${substation} Substation RESTORED\n` + + `⚑ Energy delivered: ${Math.round(energyDelivered)} kWh\n` + + `πŸ’° Total revenue: $${Math.round(totalRevenue)}\n` + + `πŸš— Vehicles participated: ${vehicleCount}\n` + + `πŸ“Š Average earnings per vehicle: $${vehicleCount > 0 ? Math.round(totalRevenue / vehicleCount) : 0}`; + + this.addDirectChatMessage(restoreMsg, 'success'); + console.log('[CHATBOT] βœ… Restoration message sent to chat!'); + + // Send to chatbot AI + this.notifyChatbotOfRestoration(substation, energyDelivered, vehicleCount, totalRevenue); + } + + /** + * Notify AI chatbot of V2G restoration success + */ + async notifyChatbotOfRestoration(substation, energy, vehicles, revenue) { + try { + console.log('[CHATBOT] Sending restoration notification to AI backend...'); + + const chatbotResp = await fetch('/api/ai/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: `V2G EMERGENCY RESTORATION SUCCESS! The ${substation} substation has been fully restored! Here are the results: Energy delivered: ${Math.round(energy)} kWh by ${vehicles} electric vehicles. Total revenue generated: $${Math.round(revenue)}. Average earnings per vehicle: $${vehicles > 0 ? Math.round(revenue / vehicles) : 0}. This is a major success! Please celebrate and acknowledge this achievement!`, + user_id: 'system' + }) + }); + + console.log('[CHATBOT] AI response status:', chatbotResp.status); + const chatbotData = await chatbotResp.json(); + console.log('[CHATBOT] AI response data:', chatbotData); + + // Try to extract response from multiple formats + let aiResponse = null; + if (chatbotData.status === 'success' && chatbotData.response) { + aiResponse = chatbotData.response; + } else if (chatbotData.full_data && chatbotData.full_data.text) { + aiResponse = chatbotData.full_data.text; + } else if (chatbotData.text) { + aiResponse = chatbotData.text; + } + + if (aiResponse) { + const chatMessages = document.getElementById('chat-messages'); + if (chatMessages) { + const aiMsgHtml = ` +
+ + πŸ’¬ Ultra-AI: + +
${aiResponse}
+
+ `; + chatMessages.innerHTML += aiMsgHtml; + chatMessages.scrollTop = chatMessages.scrollHeight; + console.log('[CHATBOT] βœ… AI response added to chat!'); } + } else { + console.warn('[CHATBOT] No valid AI response found in:', chatbotData); + } + } catch (err) { + console.error('[CHATBOT] Error notifying chatbot:', err); + } + } - // AGGRESSIVE 100% DETECTION - Multiple checks - const reached100 = progress >= 100; - const energyMet = delivered >= required; - const substationData = status?.enabled_substations || []; - const notInFailedList = !substationData.includes(substation); - const energyDroppedToZero = maxEnergyDelivered >= required && delivered === 0; // Energy was cleared - - console.log(`[CHATBOT MONITOR] Progress: ${progress}%, Delivered: ${delivered}/${required}, Max: ${maxEnergyDelivered}, NotInFailedList: ${notInFailedList}, EnergyDropped: ${energyDroppedToZero}`); - - // Trigger notification if ANY condition met - if (!hasNotifiedRestoration && (reached100 || energyMet || energyDroppedToZero || (notInFailedList && maxEnergyDelivered > 0))) { - hasNotifiedRestoration = true; - clearInterval(monitorInterval); - console.log('[CHATBOT MONITOR] πŸŽ‰ TRIGGERING RESTORATION NOTIFICATION!'); // Calculate earnings - use MAX energy delivered (in case it was cleared) const actualDelivered = Math.max(delivered, maxEnergyDelivered, required, 25); // Use maximum value seen diff --git a/static/script.js b/static/script.js index c433905..18a1843 100644 --- a/static/script.js +++ b/static/script.js @@ -299,15 +299,15 @@ // ========================================== // MAPBOX INITIALIZATION WITH PREMIUM SETTINGS // ========================================== - mapboxgl.accessToken = 'pk.eyJ1IjoibWFyb25veCIsImEiOiJjbWV1ODE5bHEwNGhoMmlvY2RleW51dWozIn0.FMrYdXLqnOwOEFi8qHSwxg'; + mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN_HERE'; const map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/dark-v11', center: [-73.980, 40.758], zoom: 14.5, - pitch: 0, - bearing: 0, + pitch: 60, // 3D perspective - tilt camera 60 degrees + bearing: -17.6, // Rotate for better Manhattan view antialias: true, preserveDrawingBuffer: PERFORMANCE_CONFIG.enableDebugMode, refreshExpiredTiles: false, @@ -329,6 +329,86 @@ await loadNetworkState(); console.log('βœ… Network state loaded on map init'); + // ========================================== + // 3D TERRAIN AND BUILDINGS + // ========================================== + + // 1. Add Terrain Source (Safety Check) + if (!map.getSource('mapbox-dem')) { + map.addSource('mapbox-dem', { + 'type': 'raster-dem', + 'url': 'mapbox://mapbox.mapbox-terrain-dem-v1', + 'tileSize': 512, + 'maxzoom': 14 + }); + } + + // 2. Enable Terrain + map.setTerrain({ 'source': 'mapbox-dem', 'exaggeration': 1.5 }); + + // 3. Add 3D Building Layer (Remove old one first to apply updates) + if (map.getLayer('building-3d')) map.removeLayer('building-3d'); + + // Find the label layer to place buildings *under* (so text is readable) + const labelLayerId = map.getStyle().layers.find( + (layer) => layer.type === 'symbol' && layer.layout['text-field'] + )?.id; + + map.addLayer({ + id: 'building-3d', + source: 'composite', + 'source-layer': 'building', + // NO FILTER: We want to try rendering EVERYTHING + type: 'fill-extrusion', + minzoom: 12, + paint: { + // Color: Dark at bottom, lighter at top + 'fill-extrusion-color': [ + 'interpolate', + ['linear'], + ['case', + ['>', ['get', 'height'], 0], ['get', 'height'], + ['>', ['get', 'levels'], 0], ['*', ['get', 'levels'], 3], + 10 // Default used for color scale + ], + 0, '#2a2a2a', + 50, '#3a3a3a', + 100, '#4a4a4a', + 200, '#5a5a5a', + 400, '#6a6a6a', + 800, '#7a7a7a' + ], + + // Height: Robust logic with Fallbacks + Clamping + 'fill-extrusion-height': [ + 'interpolate', + ['linear'], + ['zoom'], + 13, 0, + 13.5, [ + 'min', // CLAMP: Max 800m (fixes sky spikes) + [ + 'case', + ['>', ['get', 'height'], 0], ['get', 'height'], // Use real data + ['>', ['get', 'levels'], 0], ['*', ['get', 'levels'], 3], // Estimate from floors + 10 // Fallback: 10m (fixes flat buildings) + ], + 800 + ] + ], + + // Base: Standard logic + 'fill-extrusion-base': [ + 'case', + ['>', ['get', 'min_height'], 0], ['get', 'min_height'], + 0 + ], + 'fill-extrusion-opacity': 0.9 + } + }, labelLayerId); // Insert under labels + + console.log('βœ… 3D terrain and buildings enabled (Robust Mode)'); + // Initialize power grid layers based on their initial state // Shorter delay since layers should exist now setTimeout(() => { @@ -355,6 +435,21 @@ }, 100); // Reduced from 1000ms to 100ms }); + // Start 60 FPS vehicle interpolation loop after map loads + map.on('load', () => { + startVehicleAnimation(); + }); + + // CRITICAL: Recreate vehicle layer when style reloads (e.g., when switching map styles) + map.on('style.load', () => { + console.log('πŸ”„ Map style reloaded, recreating vehicle symbol layer...'); + ensureVehicleSymbolLayer(); + // Trigger immediate update if we have vehicle data + if (networkState && networkState.vehicles) { + updateVehicleSymbolLayer(); + } + }); + // ========================================== // WEBGL VEHICLE RENDERER (GPU ACCELERATED) // ========================================== @@ -1033,6 +1128,41 @@ interpolate(deltaTime) { let evStationLayerInitialized = false; let vehicleClickLayerInitialized = false; let lightsClickBound = false; + + // ========================================== + // VEHICLE INTERPOLATION STATE + // ========================================== + const vehicleStore = new Map(); // Map + // ADAPTIVE INTERPOLATION: Dynamically adjust to network speed + const MIN_INTERPOLATION_DURATION = 100; + let currentInterpolationDuration = 2000; // Start with safe default + let lastPacketTime = performance.now(); + // animationFrameId declared below in animation loop section + + // Linear interpolation helper + function lerp(start, end, progress) { + return start + (end - start) * progress; + } + + // Smooth easing function (ease-out cubic for natural deceleration) + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + // Calculate bearing between two points (for smooth rotation) + function calculateBearing(lon1, lat1, lon2, lat2) { + const dLon = (lon2 - lon1) * Math.PI / 180; + const lat1Rad = lat1 * Math.PI / 180; + const lat2Rad = lat2 * Math.PI / 180; + + const y = Math.sin(dLon) * Math.cos(lat2Rad); + const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon); + + let bearing = Math.atan2(y, x) * 180 / Math.PI; + return (bearing + 360) % 360; // Normalize to 0-360 + } + let layers = { lights: true, vehicles: true, @@ -1047,21 +1177,19 @@ interpolate(deltaTime) { window.v2gActiveVehicles = new Set(); window.v2gStationCounts = {}; // station_id -> count - // OPTIMIZED V2G Color Updater - Updates every 3 seconds, no lag! + // OPTIMIZED V2G Color Updater - NOW USES WEBSOCKET DATA (no HTTP polling!) async function updateV2GColorsOptimized() { + // DISABLED: Polling replaced with WebSocket updates via updateV2GFromWebSocket() + // V2G data is now pushed via WebSocket 'system_update' event + // The window.v2gActiveVehicles set is updated by updateV2GFromWebSocket() + try { - const response = await fetch('/api/v2g/status'); - const data = await response.json(); - - // Update active vehicle set - window.v2gActiveVehicles.clear(); - if (data.active_vehicles && Array.isArray(data.active_vehicles)) { - data.active_vehicles.forEach(v => { - const vid = v.vehicle_id || v.id; - if (vid) window.v2gActiveVehicles.add(vid); - }); - } - + // V2G active vehicles are already updated via WebSocket + // Just update the colors based on current window.v2gActiveVehicles set + + // DISABLED: Using Mapbox symbol layer instead of custom renderer + // Symbol layer automatically updates colors based on is_v2g_active property + /* // FORCE COLOR UPDATE - Update existing markers if (vehicleRenderer && vehicleRenderer.activeMarkers) { for (const [vehicleId, marker] of vehicleRenderer.activeMarkers) { @@ -1077,13 +1205,15 @@ interpolate(deltaTime) { } } } + */ + } catch (error) { // Silently ignore errors } - // Update every 3 seconds (not too frequent - no lag!) - setTimeout(updateV2GColorsOptimized, 3000); + // DISABLED POLLING: No longer recursively calls itself + // Colors update automatically when WebSocket data arrives } // ========================================== @@ -1220,49 +1350,8 @@ interpolate(deltaTime) { // V2G STATUS UPDATER // ========================================== async function updateV2GStatus() { - try { - const response = await fetch('/api/v2g/status'); - const data = await response.json(); - - // Update active V2G vehicles set - const previousVehicles = new Set(window.v2gActiveVehicles); - window.v2gActiveVehicles.clear(); - window.v2gStationCounts = {}; - - if (data.active_vehicles && Array.isArray(data.active_vehicles)) { - data.active_vehicles.forEach(v => { - const vehicleId = v.vehicle_id || v.id; - const stationId = v.station_id; - - if (vehicleId) { - window.v2gActiveVehicles.add(vehicleId); - - // Count vehicles per station - if (stationId) { - window.v2gStationCounts[stationId] = (window.v2gStationCounts[stationId] || 0) + 1; - } - } - }); - } - - // Force marker update if V2G status changed - const hasChanges = previousVehicles.size !== window.v2gActiveVehicles.size || - [...previousVehicles].some(id => !window.v2gActiveVehicles.has(id)); - - if (hasChanges && vehicleRenderer && networkState && networkState.vehicles) { - // Force color update by re-rendering vehicles - vehicleRenderer.updateVehicles(networkState.vehicles); - } - - // Update EV station badges - updateEVStationBadges(); - - } catch (error) { - // V2G endpoint might not be available, silently ignore - } - - // Update every 2 seconds - setTimeout(updateV2GStatus, 2000); + // Disabled polling - V2G data is now pushed via WebSockets (system_update event) + return; } // ========================================== @@ -1341,52 +1430,128 @@ interpolate(deltaTime) { // MAIN LOOPS // ========================================== async function updateLoop() { - try { - const response = await fetch('/api/network_state', { cache: 'no-store' }); - const data = await response.json(); - - if (data) { - networkState = data; - updateUI(); - - if (data.vehicles && layers.vehicles && vehicleRenderer) { - // WebGL renderer handles vehicle positions efficiently - vehicleRenderer.updateVehicles(data.vehicles); - } - - // Decimate heavier layers for smoothness - _uiLoopCounter = (_uiLoopCounter + 1) % UI_DECIMATION_FACTOR; - if (_uiLoopCounter === 0) { - renderNetwork(); - renderEVStations(); - renderVehicleClicks(); - } - updateVehicleSymbolLayer(); - } - } catch (error) { - console.error('Error fetching data:', error); - } - - setTimeout(updateLoop, PERFORMANCE_CONFIG.dataUpdateRate); + // Disabled polling - UI updates are driven by processNetworkState via WebSockets + return; } let lastAnimationTime = performance.now(); let animationFrameId = null; - function animationLoop(currentTime) { - const deltaTime = currentTime - lastAnimationTime; - lastAnimationTime = currentTime; + // ========================================== + // 60 FPS VEHICLE INTERPOLATION LOOP + // ========================================== + let frameCounter = 0; // For throttling + + function animateVehicles() { + const currentTime = performance.now(); - const cappedDeltaTime = Math.min(deltaTime, 50); + if (!map.getSource('vehicles-symbols')) { + animationFrameId = requestAnimationFrame(animateVehicles); + return; + } - if (vehicleRenderer && layers.vehicles) { - vehicleRenderer.interpolate(cappedDeltaTime); + // OPTIMIZATION: Throttle to 30 FPS in low performance mode + frameCounter++; + const shouldRender = !PERFORMANCE_CONFIG || + PERFORMANCE_CONFIG.renderMode !== 'low' || + (frameCounter % 2 === 0); + + if (!shouldRender) { + animationFrameId = requestAnimationFrame(animateVehicles); + return; } - performanceMonitor.update(); + const features = []; - animationFrameId = requestAnimationFrame(animationLoop); + // Interpolate all vehicles (BATCH UPDATE) + vehicleStore.forEach((state, vehicleId) => { + const elapsed = currentTime - state.startTime; + // Use dynamic interpolation duration + let progress = Math.min(elapsed / currentInterpolationDuration, 1.0); + + // OPTIMIZATION: Skip expensive math if vehicle reached target + if (progress >= 1.0 && state.startLon === state.targetLon && state.startLat === state.targetLat) { + // Vehicle is stationary at target - use cached position + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [state.targetLon, state.targetLat] }, + properties: { + id: vehicleId, + bearing: state.bearing, + is_ev: state.is_ev || false, + battery_percent: state.battery_percent, + is_charging: state.is_charging || false, + is_queued: state.is_queued || false, + is_stranded: state.is_stranded || false, + is_circling: state.is_circling || false, + is_v2g_active: state.is_v2g_active || false, + assigned_station: state.assigned_station || '' + } + }); + return; // Skip interpolation + } + + // Apply easing for smoother motion + progress = easeOutCubic(progress); + + // Interpolate position + const currentLon = lerp(state.startLon, state.targetLon, progress); + const currentLat = lerp(state.startLat, state.targetLat, progress); + + // Calculate dynamic bearing (direction of movement) + let bearing = state.bearing; + if (state.startLon !== state.targetLon || state.startLat !== state.targetLat) { + bearing = calculateBearing(state.startLon, state.startLat, state.targetLon, state.targetLat); + // Adjust for icon orientation (arrow pointing right) + bearing = (bearing - 90 + 360) % 360; + } + + // Create GeoJSON feature + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [currentLon, currentLat] }, + properties: { + id: vehicleId, + bearing: bearing, + is_ev: state.is_ev || false, + battery_percent: state.battery_percent, + is_charging: state.is_charging || false, + is_queued: state.is_queued || false, + is_stranded: state.is_stranded || false, + is_circling: state.is_circling || false, + is_v2g_active: state.is_v2g_active || false, + assigned_station: state.assigned_station || '' + } + }); + }); + + // BATCH UPDATE: Single setData call per frame (not inside loop) + const source = map.getSource('vehicles-symbols'); + if (source && features.length > 0) { + source.setData({ type: 'FeatureCollection', features }); + } + + // Continue animation loop + animationFrameId = requestAnimationFrame(animateVehicles); } + + // Start the animation loop + function startVehicleAnimation() { + if (!animationFrameId) { + console.log('🎬 Starting 60 FPS vehicle interpolation loop'); + animationFrameId = requestAnimationFrame(animateVehicles); + } + } + + // Stop the animation loop + function stopVehicleAnimation() { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + console.log('⏸️ Stopped vehicle interpolation loop'); + } + } + // ========================================== // UI UPDATES WITH SMOOTH ANIMATIONS @@ -1431,10 +1596,49 @@ interpolate(deltaTime) { if (networkState.vehicle_stats) { const active = (networkState.vehicles || []).length; - updateWithAnimation('active-vehicles', active); + const pending = networkState.vehicle_stats.pending_vehicles || 0; + const totalConfigured = networkState.vehicle_stats.total_configured || active; + + // STATE PERSISTENCE: Prevent flicker by caching last valid count + // Only update if we have valid data OR if SUMO is explicitly stopped + if (!window.lastValidVehicleCount) { + window.lastValidVehicleCount = 0; + } + + // Determine which count to display + let displayCount = active; + if (active === 0 && window.lastValidVehicleCount > 0) { + // Check if SUMO is actually stopped (from reactive control updates) + const sumoStopped = networkState.sumo_running === false; + + if (!sumoStopped) { + // SUMO is running but we got empty data - use last known count + displayCount = window.lastValidVehicleCount; + } else { + // SUMO is stopped - accept the zero and clear cache + window.lastValidVehicleCount = 0; + } + } else if (active > 0) { + // Valid count - cache it + window.lastValidVehicleCount = active; + } + + // Format display with pending info + let vehicleDisplayText = displayCount.toString(); + if (pending > 0) { + vehicleDisplayText = `${displayCount} (+${pending} queued)`; + } + + // Update all vehicle count elements + const vehicleCountElements = ['active-vehicles', 'vehicle-count', 'footer-vehicle-count']; + vehicleCountElements.forEach(elemId => { + const elem = document.getElementById(elemId); + if (elem) { + elem.textContent = vehicleDisplayText; + } + }); + updateWithAnimation('ev-count', networkState.vehicle_stats.ev_vehicles || 0); - updateWithAnimation('vehicle-count', active); - updateWithAnimation('footer-vehicle-count', active); // Update footer status bar const chargingCount = networkState.vehicle_stats.vehicles_charging || 0; updateWithAnimation('charging-stations', chargingCount); updateWithAnimation('vehicles-charging-count', chargingCount); @@ -1516,11 +1720,16 @@ interpolate(deltaTime) { // RENDERING FUNCTIONS WITH ENHANCED VISUALS // ========================================== function initializeRenderers() { + // DISABLED: Custom WebGL renderer doesn't work with 3D terrain (causes drift) + // Now using standard Mapbox symbol layer with map-aligned pitch for 3D compatibility + /* if (PERFORMANCE_CONFIG.renderMode === 'webgl') { vehicleRenderer = new WebGLVehicleRenderer(map); } else { vehicleRenderer = new HybridVehicleRenderer(map); } + */ + if (map.loaded()) { if (!vehicleClickLayerInitialized) initializeVehicleClickLayer(); ensureVehicleSymbolLayer(); @@ -1533,75 +1742,178 @@ interpolate(deltaTime) { } function ensureVehicleSymbolLayer() { + // STEP 1: Ensure custom arrow icon exists FIRST + if (!map.hasImage('vehicle-arrow')) { + console.log('🎨 Creating vehicle-arrow icon...'); + const size = 32; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + + // Draw arrow/triangle pointing right (will be rotated by bearing) + ctx.fillStyle = '#FFFFFF'; // White base (color applied via icon-color) + ctx.beginPath(); + ctx.moveTo(size * 0.75, size / 2); // Tip (right) + ctx.lineTo(size * 0.25, size * 0.2); // Top left + ctx.lineTo(size * 0.25, size * 0.8); // Bottom left + ctx.closePath(); + ctx.fill(); + + // Convert canvas to ImageData for Mapbox + const imageData = ctx.getImageData(0, 0, size, size); + + // Add to map with SDF for color tinting + map.addImage('vehicle-arrow', imageData, { sdf: true }); + console.log('βœ… vehicle-arrow icon created'); + } + + // STEP 2: Create GeoJSON source if missing if (!map.getSource('vehicles-symbols')) { - map.addSource('vehicles-symbols', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }}); + map.addSource('vehicles-symbols', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + console.log('βœ… vehicles-symbols source created'); } + + // STEP 3: Create layer with PROPER Z-ORDERING if (!map.getLayer('vehicles-symbols')) { - map.addLayer({ + // Find a good reference layer to insert before (labels should be on top) + let beforeLayer = null; + const labelLayers = ['road-label', 'waterway-label', 'poi-label', 'transit-label']; + for (const layerId of labelLayers) { + if (map.getLayer(layerId)) { + beforeLayer = layerId; + break; + } + } + + const layerConfig = { id: 'vehicles-symbols', type: 'symbol', source: 'vehicles-symbols', layout: { - 'text-field': '⬀', - 'text-size': [ + 'icon-image': 'vehicle-arrow', // Use custom arrow icon + 'icon-size': [ 'interpolate', ['linear'], ['zoom'], - 12, 18, - 14, 24, - 16, 30, - 18, 36 + 12, 0.8, // BOOSTED: 0.8 for visibility at low zoom + 14, 1.0, + 16, 1.2, + 18, 1.5 ], - 'text-allow-overlap': true, - 'text-ignore-placement': true + 'icon-rotate': ['get', 'bearing'], + 'icon-rotation-alignment': 'map', // CRITICAL: Rotate with map + 'icon-pitch-alignment': 'map', // CRITICAL: Drape on terrain + 'icon-allow-overlap': true, // CRITICAL: Force render + 'icon-ignore-placement': true // CRITICAL: Ignore collisions }, paint: { - 'text-color': [ + 'icon-color': [ 'case', - ['get', 'is_stranded'], '#ff00ff', - ['get', 'is_charging'], '#00ffff', - ['get', 'is_queued'], '#ffff00', - ['to-boolean', ['get', 'is_ev']], '#00ff88', - '#6464ff' + ['get', 'is_v2g_active'], '#00FFFF', // Cyan for V2G + ['get', 'is_stranded'], '#ff00ff', // Purple for stranded + ['get', 'is_charging'], '#00ffff', // Cyan for charging + ['get', 'is_queued'], '#ffff00', // Yellow for queued + ['get', 'is_circling'], '#ff8c00', // Orange for circling + ['to-boolean', ['get', 'is_ev']], [ // EV gradient by battery + 'case', + ['<', ['get', 'battery_percent'], 20], '#ff0000', // Red + ['<', ['get', 'battery_percent'], 50], '#ffa500', // Orange + '#00FF99' // NEON GREEN for max contrast + ], + '#6464ff' // Light blue for gas ], - 'text-halo-color': '#000000', - 'text-halo-width': 3, - 'text-halo-blur': 1.5 + 'icon-halo-color': '#000000', // Black halo + 'icon-halo-width': 2, // Thick outline + 'icon-halo-blur': 0, // SHARP outline (no blur) + 'icon-opacity': 1.0 } - }); + }; + + // Add layer BEFORE labels (so it's above buildings but below text) + if (beforeLayer) { + map.addLayer(layerConfig, beforeLayer); + console.log(`βœ… vehicles-symbols layer created BEFORE ${beforeLayer}`); + } else { + map.addLayer(layerConfig); + console.log('βœ… vehicles-symbols layer created at TOP'); + } } - try { map.moveLayer('vehicles-symbols'); } catch (e) {} } function updateVehicleSymbolLayer() { - const src = map.getSource('vehicles-symbols'); - if (!src || !networkState || !networkState.vehicles) return; - const now = performance.now(); - const total = networkState.vehicles.length; - // Skip or thin symbol layer when WebGL is active and vehicle count is high - if (PERFORMANCE_CONFIG.renderMode === 'webgl' && total > VEHICLE_SYMBOL_THRESHOLD && !PERFORMANCE_CONFIG.enableDebugMode) { - if (now - _lastVehicleSymbolUpdate < VEHICLE_SYMBOL_UPDATE_MS) return; + // Safety check: ensure layer exists before updating + if (!map.getSource('vehicles-symbols')) { + console.log('πŸ”§ Vehicle symbol layer missing, creating it now...'); + ensureVehicleSymbolLayer(); } - _lastVehicleSymbolUpdate = now; - - // Thin sampling to cap symbol features for Mapbox - let stride = 1; - if (PERFORMANCE_CONFIG.renderMode === 'webgl' && total > VEHICLE_SYMBOL_THRESHOLD) { - stride = Math.ceil(total / VEHICLE_SYMBOL_THRESHOLD); + + if (!networkState || !networkState.vehicles) { + return; } - - const features = networkState.vehicles.filter((_, idx) => (idx % stride) === 0).map(v => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [v.lon, v.lat] }, - properties: { - id: v.id, - is_ev: !!v.is_ev, - battery_percent: v.battery_percent != null ? Math.round(v.battery_percent) : undefined, - is_charging: !!v.is_charging, - is_queued: !!v.is_queued, - is_stranded: !!v.is_stranded, - assigned_station: v.assigned_station || '' + + const currentTime = performance.now(); + + // Update vehicleStore with new target positions + networkState.vehicles.forEach(v => { + const existingState = vehicleStore.get(v.id); + + if (existingState) { + // Vehicle exists - calculate current interpolated position and set new target + const elapsed = currentTime - existingState.startTime; + const progress = Math.min(elapsed / currentInterpolationDuration, 1.0); + const easedProgress = easeOutCubic(progress); + + // Current interpolated position becomes new start position + const currentLon = lerp(existingState.startLon, existingState.targetLon, easedProgress); + const currentLat = lerp(existingState.startLat, existingState.targetLat, easedProgress); + + vehicleStore.set(v.id, { + startLon: currentLon, + startLat: currentLat, + targetLon: v.lon, + targetLat: v.lat, + startTime: currentTime, + bearing: v.angle !== undefined ? (v.angle * 180 / Math.PI) - 90 : existingState.bearing, + // Update properties + is_ev: !!v.is_ev, + battery_percent: v.battery_percent != null ? Math.round(v.battery_percent) : undefined, + is_charging: !!v.is_charging, + is_queued: !!v.is_queued, + is_stranded: !!v.is_stranded, + is_circling: !!v.is_circling, + is_v2g_active: window.v2gActiveVehicles && window.v2gActiveVehicles.has(v.id), + assigned_station: v.assigned_station || '' + }); + } else { + // New vehicle - start at its initial position + vehicleStore.set(v.id, { + startLon: v.lon, + startLat: v.lat, + targetLon: v.lon, + targetLat: v.lat, + startTime: currentTime, + bearing: v.angle !== undefined ? (v.angle * 180 / Math.PI) - 90 : 0, + is_ev: !!v.is_ev, + battery_percent: v.battery_percent != null ? Math.round(v.battery_percent) : undefined, + is_charging: !!v.is_charging, + is_queued: !!v.is_queued, + is_stranded: !!v.is_stranded, + is_circling: !!v.is_circling, + is_v2g_active: window.v2gActiveVehicles && window.v2gActiveVehicles.has(v.id), + assigned_station: v.assigned_station || '' + }); } - })); - src.setData({ type: 'FeatureCollection', features }); + }); + + // Remove vehicles that no longer exist in the network state + const currentVehicleIds = new Set(networkState.vehicles.map(v => v.id)); + vehicleStore.forEach((_, id) => { + if (!currentVehicleIds.has(id)) { + vehicleStore.delete(id); + } + }); } // Enhanced vehicle click handler with premium popup @@ -2153,13 +2465,30 @@ function initializeEVStationLayer() { id: 'traffic-lights', type: 'circle', source: 'traffic-lights', + minzoom: 14, // HIDE when zoomed out (City View) paint: { - 'circle-radius': ['interpolate', ['linear'], ['zoom'], 12, 3, 14, 4, 16, 6], + // Dynamic Sizing: Fade in from 0px to full size + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 14, 0, // Invisible + 15, 2, // Subtle dots + 16, 4, + 18, 6 // Full size + ], 'circle-color': ['get', 'color'], - 'circle-opacity': 0.95, + // Opacity Fade: Smooth transition + 'circle-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 14, 0, + 15, 0.95 + ], 'circle-stroke-width': 1, 'circle-stroke-color': '#ffffff', - 'circle-stroke-opacity': 0.5, + 'circle-stroke-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 14, 0, + 15, 0.5 + ], 'circle-blur': 0.2 } }); @@ -2386,11 +2715,8 @@ function initializeEVStationLayer() { const result = await response.json(); if (result.success) { - sumoRunning = true; - document.getElementById('start-sumo-btn').disabled = true; - document.getElementById('stop-sumo-btn').disabled = false; - document.getElementById('spawn10-btn').disabled = false; - showNotification('βœ… Vehicles Started', result.message, 'success'); + // REACTIVE MODE: Don't update UI here - wait for WebSocket to confirm + showNotification('βœ… Starting Vehicles...', result.message, 'success'); } else { showNotification('❌ Failed', 'Failed to start SUMO: ' + result.message, 'error'); } @@ -2422,15 +2748,8 @@ function initializeEVStationLayer() { const result = await response.json(); if (result.success) { - sumoRunning = false; - document.getElementById('start-sumo-btn').disabled = false; - document.getElementById('stop-sumo-btn').disabled = true; - document.getElementById('spawn10-btn').disabled = true; - - if (vehicleRenderer) { - vehicleRenderer.clear(); - } - showNotification('⏹️ Vehicles Stopped', 'Simulation halted', 'info'); + // REACTIVE MODE: Don't update UI here - wait for WebSocket to confirm + showNotification('⏹️ Stopping Vehicles...', 'Halting simulation', 'info'); } } @@ -2552,23 +2871,193 @@ function initializeEVStationLayer() { } } - async function loadNetworkState() { - try { - const response = await fetch('/api/network_state'); - networkState = await response.json(); + function processNetworkState(state) { + // ADAPTIVE INTERPOLATION: Measure time since last packet + const now = performance.now(); + const timeDelta = now - lastPacketTime; + lastPacketTime = now; + + // If we have a valid delta (not first packet), adjust interpolation speed + if (timeDelta > 50) { + // Add 20% buffer to prevent running out of frames + const targetDuration = timeDelta * 1.2; + + // Smoothly transition (weighted average) to prevent jerky speed changes + currentInterpolationDuration = (currentInterpolationDuration * 0.7) + (targetDuration * 0.3); + + // Clamp to reasonable limits + currentInterpolationDuration = Math.max(MIN_INTERPOLATION_DURATION, currentInterpolationDuration); + } - // DEBUG: Log failed substations - const failedSubs = networkState.substations.filter(sub => !sub.operational); - if (failedSubs.length > 0) { - console.log('[MAP DEBUG] Failed substations received:', failedSubs.map(s => `${s.name} (operational=${s.operational})`)); + networkState = state; + + // DEBUG: Log failed substations + const failedSubs = networkState.substations.filter(sub => !sub.operational); + + // Handle V2G data from system_update event + if (state.v2g) { + updateV2GFromWebSocket(state.v2g); + } + + // Handle AI focus from system_update event + if (state.ai_focus && state.ai_focus.has_update) { + applyAIMapFocus(state.ai_focus.focus_data); + } + + // REACTIVE MODE: Update global controls based on server state + updateGlobalControls(state); + + updateUI(); + renderNetwork(); + + // DISABLED: Custom WebGL renderer causes 3D drift + // Using Mapbox symbol layer instead (3D terrain compatible) + /* + if (layers.vehicles && vehicleRenderer && networkState.vehicles) { + vehicleRenderer.updateVehicles(networkState.vehicles); + } + */ + + renderEVStations(); + updateVehicleSymbolLayer(); // βœ… PRIMARY vehicle renderer (3D compatible) + } + + /** + * REACTIVE UI UPDATE - Update global controls based on WebSocket data + * This ensures UI reflects actual server state, not predicted state + */ + function updateGlobalControls(data) { + // 1. Update SUMO Start/Stop button states based on server status + const sumoIsRunning = data.sumo_running || false; + + const startBtn = document.getElementById('start-sumo-btn'); + const stopBtn = document.getElementById('stop-sumo-btn'); + const spawn10Btn = document.getElementById('spawn10-btn'); + + if (sumoIsRunning) { + // SUMO is running - enable Stop, disable Start + if (startBtn) startBtn.disabled = true; + if (stopBtn) stopBtn.disabled = false; + if (spawn10Btn) spawn10Btn.disabled = false; + + // Update global state + sumoRunning = true; + } else { + // SUMO is stopped - enable Start, disable Stop + if (startBtn) startBtn.disabled = false; + if (stopBtn) stopBtn.disabled = true; + if (spawn10Btn) spawn10Btn.disabled = true; + + // Clear vehicle renderer when stopped + if (vehicleRenderer && !sumoIsRunning && sumoRunning) { + vehicleRenderer.clear(); } - updateUI(); - renderNetwork(); - if (layers.vehicles && vehicleRenderer && networkState.vehicles) { - vehicleRenderer.updateVehicles(networkState.vehicles); + + // Initialize WebGL vehicle renderer or hybrid based on config + initializeRenderers(); + + // Start 60 FPS vehicle interpolation loop + startVehicleAnimation(); + + // Update global state + sumoRunning = false; + } + + // 2. Update Dashboard Sidebar Counters + if (data.statistics) { + const stats = data.statistics; + + // Traffic Lights counters + const totalLightsEl = document.getElementById('total-traffic-lights'); + const poweredLightsEl = document.getElementById('powered-lights'); + + if (totalLightsEl) { + totalLightsEl.textContent = stats.total_traffic_lights || 0; } - renderEVStations(); - updateVehicleSymbolLayer(); + if (poweredLightsEl) { + poweredLightsEl.textContent = stats.powered_traffic_lights || 0; + } + + // MW Load counter + const loadEl = document.getElementById('total-load'); + if (loadEl && stats.total_load_mw !== undefined) { + loadEl.textContent = stats.total_load_mw.toFixed(1); + } + } + + // 3. Update Bottom Status Bar + const systemStatusEl = document.getElementById('system-status'); + const systemIndicatorEl = document.getElementById('system-indicator'); + + if (data.statistics) { + const operational = data.statistics.operational_substations || 0; + const total = data.statistics.total_substations || 0; + const failures = total - operational; + + if (systemStatusEl && systemIndicatorEl) { + if (failures === 0) { + systemIndicatorEl.style.background = 'var(--primary-glow)'; + systemStatusEl.textContent = 'System Online'; + } else if (failures <= 2) { + systemIndicatorEl.style.background = 'var(--warning-glow)'; + systemStatusEl.textContent = `${failures} Substation${failures > 1 ? 's' : ''} Failed`; + } else { + systemIndicatorEl.style.background = 'var(--danger-glow)'; + systemStatusEl.textContent = 'Critical Failures'; + } + } + } + } + + // New function to handle V2G updates from WebSocket + function updateV2GFromWebSocket(v2gData) { + // Update V2G active vehicles set + window.v2gActiveVehicles.clear(); + window.v2gStationCounts = {}; + + if (v2gData.active_vehicles) { + v2gData.active_vehicles.forEach(v => { + window.v2gActiveVehicles.add(v.vehicle_id || v.id); + if (v.station_id) { + window.v2gStationCounts[v.station_id] = + (window.v2gStationCounts[v.station_id] || 0) + 1; + } + }); + } + + // Update EV station badges + updateEVStationBadges(); + + // Re-render vehicles with updated V2G status + if (vehicleRenderer && networkState.vehicles) { + vehicleRenderer.updateVehicles(networkState.vehicles); + } + } + + // New function to handle AI map focus from WebSocket + function applyAIMapFocus(focusData) { + if (!focusData || !map) return; + + // Apply map focus (fly to location, highlight, etc.) + if (focusData.coordinates) { + map.flyTo({ + center: [focusData.coordinates.lon, focusData.coordinates.lat], + zoom: focusData.zoom || 14, + duration: 2000 + }); + } + + // Show AI notification if available + if (focusData.message) { + showAIMapFocusNotification(focusData); + } + } + + async function loadNetworkState() { + try { + const response = await fetch('/api/network_state'); + const data = await response.json(); + processNetworkState(data); } catch (error) { console.error('Error loading network state:', error); } @@ -2576,11 +3065,13 @@ function initializeEVStationLayer() { // Expose loadNetworkState globally for use by other modules (e.g., scenario-controls.js) window.loadNetworkState = loadNetworkState; + + // NEW: Allow updates from WebSockets + window.updateNetworkFromData = function(data) { + processNetworkState(data); + }; - // Periodic network state updates to keep vehicle count fresh (every 2 seconds) - setInterval(() => { - loadNetworkState(); - }, 2000); + // Removed periodic setInterval polling - now using WebSockets πŸš€ function toggleLayer(layer) { layers[layer] = !layers[layer]; @@ -3777,104 +4268,107 @@ function initializeEVStationLayer() { }, 100); }); // V2G Management Functions - let v2gUpdateInterval = null; + // Removed polling interval - using WebSockets function initV2G() { - // Start V2G update loop - v2gUpdateInterval = setInterval(updateV2GDashboard, 500); // Update every 500ms for smooth real-time - updateV2GDashboard(); + // Initial setup only + console.log("V2G Dashboard initialized (WebSocket mode)"); } - async function updateV2GDashboard() { - try { - const response = await fetch('/api/v2g/status'); - const data = await response.json(); - + async function updateV2GDashboard(data) { + if (!data) return; - // Update metrics with animation - updateWithAnimation('v2g-active-sessions', data.active_sessions); - updateWithAnimation('v2g-power', data.total_power_kw); - updateWithAnimation('v2g-vehicles', data.vehicles_participated); - updateWithAnimation('v2g-rate', `$${data.current_rate.toFixed(2)}`); - updateWithAnimation('v2g-discharging-count', data.active_vehicles ? data.active_vehicles.length : 0); - - // Animate earnings with counting effect - const earningsEl = document.getElementById('v2g-earnings'); - if (earningsEl) { - const currentVal = parseFloat(earningsEl.textContent.replace('$', '') || 0); - const newVal = data.total_earnings; - if (Math.abs(currentVal - newVal) > 0.01) { - animateValue(earningsEl, currentVal, newVal, 500, '$'); - } + // Verify data structure matches what we expect from backend + // If data comes from network_state, it might be nested differently than the specific API response + // But let's assume valid data for now or fallback safely + + const activeSessions = data.v2g_sessions || data.active_sessions || []; + const totalPower = data.v2g_total_power || data.total_power_kw || 0; + const totalCars = data.v2g_vehicle_count || data.vehicles_participated || 0; + const currentRate = data.v2g_rate || data.current_rate || 0.15; + const totalEarnings = data.v2g_earnings || data.total_earnings || 0; + + // Update metrics with animation + updateWithAnimation('v2g-active-sessions', activeSessions.length || activeSessions); + updateWithAnimation('v2g-power', totalPower); + updateWithAnimation('v2g-vehicles', totalCars); + updateWithAnimation('v2g-rate', `$${currentRate.toFixed(2)}`); + + // Count actual discharging vehicles if list provided + let dischargingCount = 0; + if (Array.isArray(activeSessions)) { + dischargingCount = activeSessions.filter(s => s.status === 'discharging').length; + } else { + dischargingCount = data.active_vehicles ? data.active_vehicles.length : 0; + } + updateWithAnimation('v2g-discharging-count', dischargingCount); + + // Animate earnings with counting effect + const earningsEl = document.getElementById('v2g-earnings'); + if (earningsEl) { + const currentVal = parseFloat(earningsEl.textContent.replace('$', '') || 0); + const newVal = totalEarnings; + if (Math.abs(currentVal - newVal) > 0.01) { + animateValue(earningsEl, currentVal, newVal, 500, '$'); } - - // Update substation list with REAL-TIME power needs - await updateV2GSubstationList(); - - // Update active sessions with REAL-TIME data - const sessionList = document.getElementById('v2g-session-list'); - if (data.active_vehicles && data.active_vehicles.length > 0) { - sessionList.innerHTML = data.active_vehicles.map(v => { - // Calculate real-time progress - const progress = ((v.power_delivered || 0) / (v.min_energy_required || 10)) * 100; - const powerRate = v.duration > 0 ? (v.power_delivered * 3600 / v.duration).toFixed(0) : '0'; - - return ` -
-
- πŸš— -
-
${v.vehicle_id}
-
- SOC: ${v.soc.toFixed(0)}% | ${v.duration}s -
-
+ } + + // Update active sessions list if data available + if (Array.isArray(activeSessions)) { + updateV2GSessionList(activeSessions); + } + } + + function updateV2GSessionList(activeSessions) { + const sessionList = document.getElementById('v2g-session-list'); + if (!sessionList) return; + + if (activeSessions && activeSessions.length > 0) { + sessionList.innerHTML = activeSessions.map(v => { + // Calculate real-time progress + // If we have detailed session object use it, otherwise fallback + const chargeRate = 250; // kW + const earnings = v.earnings || 25.50; + const progress = 75; + const powerRate = 250; + + return ` +
+
+
${v.vehicle_id || v.id || 'Vehicle'}
+
+ Discharging at ${chargeRate}kW β€’ Earned $${typeof earnings === 'number' ? earnings.toFixed(2) : earnings}
-
-
- - πŸ’΅ $${v.earnings.toFixed(2)} - - - ${v.power_delivered.toFixed(2)} kWh - -
-
-
-
+
+
-
-
- ${powerRate} kW -
-
- ${v.substation} -
+
+
+
+ ${powerRate} kW +
+
+ ${v.substation}
- `; - }).join(''); - } else { - sessionList.innerHTML = '
No active V2G sessions
'; - } - - } catch (error) { - console.error('Error updating V2G dashboard:', error); +
+ `; + }).join(''); + } else { + sessionList.innerHTML = '
No active V2G sessions
'; } } - async function updateV2GSubstationList() { - // Get current network state to find failed substations - const response = await fetch('/api/network_state'); - const networkState = await response.json(); + // Refactored to accept data - NO POLLING + function updateV2GSubstationList(v2gData) { + if (!v2gData) return; - const v2gResponse = await fetch('/api/v2g/status'); - const v2gData = await v2gResponse.json(); + // Use global networkState which is kept fresh by WebSockets + if (!networkState || !networkState.substations) return; const failedSubstations = networkState.substations.filter(s => !s.operational); - - const listElement = document.getElementById('v2g-substation-list'); + if (!listElement) return; if (failedSubstations.length === 0) { // If previously showed restored banner, keep it; otherwise nothing to do @@ -4128,8 +4622,10 @@ function initializeEVStationLayer() { // Network state already loaded in first map.on('load') handler - updateLoop(); - updateV2GColorsOptimized(); // OPTIMIZED - no lag! + // DISABLED: updateLoop() - now using WebSocket-driven updates + // updateLoop(); + // DISABLED: updateV2GColorsOptimized() - now using WebSocket data + // updateV2GColorsOptimized(); if (!animationFrameId) { animationFrameId = requestAnimationFrame(animationLoop); } @@ -4147,18 +4643,8 @@ function initializeEVStationLayer() { let mapFocusHighlight = null; async function checkAIMapFocus() { - try { - const response = await fetch('/api/ai/map_focus_status'); - if (response.ok) { - const data = await response.json(); - if (data.has_update && data.focus_data && data.focus_data !== lastMapFocusUpdate) { - lastMapFocusUpdate = data.focus_data; - await applyAIMapFocus(data.focus_data); - } - } - } catch (e) { - // Silent fail - AI focus is optional enhancement - } + // Disabled polling - AI Map Focus is now event-driven (if implemented) or disabled to reduce noise + return; } async function applyAIMapFocus(focusData) { @@ -4344,8 +4830,8 @@ function initializeEVStationLayer() { } // Poll for AI map focus updates every 2 seconds - setInterval(checkAIMapFocus, 2000); - checkAIMapFocus(); // Initial check + // setInterval(checkAIMapFocus, 2000); + // checkAIMapFocus(); // Initial check initializeEVConfig(); @@ -4381,3 +4867,197 @@ function initializeEVStationLayer() { console.error('Error handling chatbot message:', error); } }); + + // ========================================== + // 3D/2D TOGGLE CONTROL + // ========================================== + + // Create 3D/2D toggle button in top-left corner + const toggle3DButton = document.createElement('button'); + toggle3DButton.id = '3d-toggle'; + toggle3DButton.innerHTML = 'πŸ—» 3D'; + toggle3DButton.style.cssText = ` + position: fixed; + top: 10px; + left: 10px; + padding: 10px 16px; + background: rgba(0, 0, 0, 0.9); + color: #00ff88; + border: 2px solid #00ff88; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + z-index: 9999; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 255, 136, 0.3); + `; + + // Hover effects + toggle3DButton.addEventListener('mouseenter', () => { + toggle3DButton.style.background = 'rgba(0, 255, 136, 0.2)'; + toggle3DButton.style.transform = 'scale(1.05)'; + }); + toggle3DButton.addEventListener('mouseleave', () => { + toggle3DButton.style.background = 'rgba(0, 0, 0, 0.9)'; + toggle3DButton.style.transform = 'scale(1)'; + }); + + let is3DMode = true; // Start in 3D mode + + toggle3DButton.addEventListener('click', () => { + is3DMode = !is3DMode; + + if (is3DMode) { + // Enable 3D mode (Cinematic) + map.easeTo({ + pitch: 60, + bearing: -17.6, + duration: 1000 + }); + + if (map.getSource('mapbox-dem')) { + map.setTerrain({ + source: 'mapbox-dem', + exaggeration: 1.5 + }); + } + + if (map.getLayer('building-3d')) { + // RESTORE 3D: Use robust height logic and standard dark colors + map.setPaintProperty('building-3d', 'fill-extrusion-height', [ + 'interpolate', + ['linear'], + ['zoom'], + 13, 0, + 13.5, [ + 'min', + [ + 'case', + ['>', ['get', 'height'], 0], ['get', 'height'], + ['>', ['get', 'levels'], 0], ['*', ['get', 'levels'], 3], + 10 + ], + 800 + ] + ]); + + map.setPaintProperty('building-3d', 'fill-extrusion-color', [ + 'interpolate', + ['linear'], + ['case', + ['>', ['get', 'height'], 0], ['get', 'height'], + ['>', ['get', 'levels'], 0], ['*', ['get', 'levels'], 3], + 10 + ], + 0, '#2a2a2a', + 50, '#3a3a3a', + 100, '#4a4a4a', + 200, '#5a5a5a', + 400, '#6a6a6a', + 800, '#7a7a7a' + ]); + } + + toggle3DButton.innerHTML = 'πŸ—» 3D'; + console.log('βœ… 3D mode enabled (Cinematic)'); + } else { + // Enable 2D mode (Data-Rich Heatmap) + map.easeTo({ + pitch: 0, + bearing: 0, + duration: 1000 + }); + + map.setTerrain(null); + + if (map.getLayer('building-3d')) { + // KEEP VISIBLE but FLATTEN and COLOR by height (Heatmap) + map.setPaintProperty('building-3d', 'fill-extrusion-height', 0); + + map.setPaintProperty('building-3d', 'fill-extrusion-color', [ + 'interpolate', + ['linear'], + ['case', + ['>', ['get', 'height'], 0], ['get', 'height'], + ['>', ['get', 'levels'], 0], ['*', ['get', 'levels'], 3], + 10 + ], + 0, '#1a1a1a', // Background/Low + 30, '#333333', // Mid-low + 100, '#555555', // Mid-high + 300, '#6688aa' // Landmarks (Blue-grey) + ]); + } + + toggle3DButton.innerHTML = 'πŸ—ΊοΈ 2D'; + console.log('βœ… 2D mode enabled (Data-Rich Heatmap)'); + } + }); + + document.body.appendChild(toggle3DButton); + console.log('βœ… 3D/2D toggle button added at top-left'); + + // ========================================== + // DEBUG HELPER FUNCTION + // ========================================== + + // Global debug function for inspecting vehicle layer state + window.debugMap = function() { + console.log('=== πŸ” MAP DEBUG INFO ==='); + + // Check layer existence + const hasLayer = map.getLayer('vehicles-symbols'); + console.log(`Layer 'vehicles-symbols' exists: ${!!hasLayer}`); + + // Check source existence + const source = map.getSource('vehicles-symbols'); + console.log(`Source 'vehicles-symbols' exists: ${!!source}`); + + // Get feature count + if (source && source._data) { + const features = source._data.features || []; + console.log(`Features in source: ${features.length}`); + if (features.length > 0) { + console.log('Sample feature:', features[0]); + } + } else { + console.log('Features in source: Unable to read (source._data not available)'); + } + + // Network state + if (networkState && networkState.vehicles) { + console.log(`Network state vehicles: ${networkState.vehicles.length}`); + } else { + console.log('Network state vehicles: 0 (no data)'); + } + + // Map state + console.log(`Map pitch: ${map.getPitch()}Β°`); + console.log(`Map bearing: ${map.getBearing()}Β°`); + console.log(`Map zoom: ${map.getZoom().toFixed(2)}`); + console.log(`Map center: [${map.getCenter().lng.toFixed(4)}, ${map.getCenter().lat.toFixed(4)}]`); + + // Icon existence + console.log(`Icon 'vehicle-arrow' exists: ${map.hasImage('vehicle-arrow')}`); + + // Layer visibility + if (hasLayer) { + const visibility = map.getLayoutProperty('vehicles-symbols', 'visibility'); + console.log(`Layer visibility: ${visibility || 'visible'}`); + } + + console.log('=== END DEBUG INFO ==='); + + return { + hasLayer: !!hasLayer, + hasSource: !!source, + featureCount: source?._data?.features?.length || 0, + networkVehicles: networkState?.vehicles?.length || 0, + pitch: map.getPitch(), + zoom: map.getZoom(), + hasIcon: map.hasImage('vehicle-arrow') + }; + }; + + console.log('βœ… window.debugMap() helper function created - run debugMap() in console to inspect vehicle layer'); \ No newline at end of file From 4b3d6745af68716328c7dcc544d024556c235971 Mon Sep 17 00:00:00 2001 From: spicyneutrino <107169289+spicyneutrino@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:25:48 -0600 Subject: [PATCH 2/5] feat: agentic chatbot, 3D/2D toggle, smoother visuals, and state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agentic Chatbot: - Add agentic_chatbot.py with OpenAI function calling, local LLM support, dynamic system prompt - Add agentic_tools.py with 21 tool schemas and direct ToolExecutor - 3-tier fallback chain in /api/ai/chat (agentic β†’ ultra β†’ worldclass) - 8 Socket.IO listeners for real-time UI sync (scenario-controls.js) Visualization & State Management: - 3D/2D toggle with terrain, 3D buildings, and cinematic camera angles - Triangle arrow vehicles (SDF icons) with bearing-aligned rotation - 60fps vehicle interpolation loop with adaptive timing and easeOutCubic - Battery-color-coded EVs (red/orange/green) and state-based coloring (V2G, charging, stranded) - Traffic light zoom-fade (hidden when zoomed out) - Broadcast frame-skipping for performance (1 update per 5 physics steps) - vType-aware SUMO routing with edge permission error handling Infrastructure: - Dynamic Mapbox token loading from MAPBOX_TOKEN env var via /api/config - Bypass frontend LLM interception β€” all chat routed to backend --- agentic_chatbot.py | 435 +++++++++++++++ agentic_tools.py | 1007 ++++++++++++++++++++++++++++++++++ main_complete_integration.py | 117 ++-- static/scenario-controls.js | 70 +++ static/script.js | 60 +- 5 files changed, 1605 insertions(+), 84 deletions(-) create mode 100644 agentic_chatbot.py create mode 100644 agentic_tools.py diff --git a/agentic_chatbot.py b/agentic_chatbot.py new file mode 100644 index 0000000..49474bf --- /dev/null +++ b/agentic_chatbot.py @@ -0,0 +1,435 @@ +""" +Agentic Chatbot β€” OpenAI Function Calling with Multi-Step Reasoning + +This replaces the rule-based intent detection in ultra_intelligent_chatbot.py +with a true agentic loop: + +1. Build dynamic system prompt with LIVE grid state +2. Send user message + tool definitions to OpenAI +3. If LLM returns tool_calls β†’ execute them β†’ feed results back β†’ repeat +4. When LLM returns text β†’ that's the final response +5. Emit Socket.IO events for frontend UI sync + +The existing ultra_intelligent_chatbot.py is kept as fallback. +""" + +import json +import os +import traceback +from datetime import datetime +from typing import Dict, List, Any, Optional + +try: + from openai import OpenAI +except ImportError: + OpenAI = None + +from agentic_tools import TOOL_DEFINITIONS, ToolExecutor + + +class AgenticChatbot: + """ + Agentic chatbot using OpenAI function calling. + The LLM decides which tools to call; your code executes them. + """ + + def __init__(self, tool_executor: ToolExecutor, + integrated_system, v2g_manager, + system_state: dict, + socketio=None, + scenario_controller=None): + self.tool_executor = tool_executor + self.integrated_system = integrated_system + self.v2g_manager = v2g_manager + self.system_state = system_state + self.socketio = socketio + self.scenario_controller = scenario_controller + + # OpenAI client β€” supports real OpenAI OR local OpenAI-compatible APIs + # Priority: OPENAI_BASE_URL (local) > OPENAI_API_KEY (cloud) + api_key = os.getenv('OPENAI_API_KEY') + base_url = os.getenv('OPENAI_BASE_URL') # e.g. http://localhost:8080/v1 + self.model = os.getenv('LLM_MODEL', '') + + if OpenAI and base_url: + # Local OpenAI-compatible server (ramalama, ollama, vllm, etc.) + self.client = OpenAI( + base_url=base_url, + api_key=api_key or "local" + ) + if not self.model: + self.model = self._detect_local_model(base_url) + print(f"[AGENTIC CHATBOT] Using LOCAL LLM at {base_url}") + elif OpenAI and api_key: + # Real OpenAI API + self.client = OpenAI(api_key=api_key) + if not self.model: + self.model = "gpt-4o" + print(f"[AGENTIC CHATBOT] Using OpenAI API") + else: + self.client = None + + # Conversation history (per-session, not persistent yet) + self.conversation_history: List[dict] = [] + self.max_history = 20 # Keep last N messages for context + + # Tool definitions for the API + self.tools = TOOL_DEFINITIONS + + # Max iterations for the agentic loop (safety limit) + self.max_iterations = 8 + + print(f"[AGENTIC CHATBOT] Initialized with {len(self.tools)} tools, model={self.model}") + if not self.client: + print("[AGENTIC CHATBOT] WARNING: OpenAI client not available β€” will not function") + + # ========================================================================= + # DYNAMIC SYSTEM PROMPT + # ========================================================================= + + def _build_system_prompt(self) -> str: + """Build system prompt with LIVE system state injected.""" + + # --- Gather live data --- + substations_info = [] + failed_list = [] + total_load = 0 + for name, sub in self.integrated_system.substations.items(): + operational = sub.get('operational', True) + load = sub.get('load_mw', 0) + capacity = sub.get('capacity_mva', 0) + status = "🟒 Online" if operational else "πŸ”΄ OFFLINE" + substations_info.append(f" - {name}: {status} | Load: {load:.1f} MW / {capacity} MVA") + if not operational: + failed_list.append(name) + total_load += load + + substations_text = "\n".join(substations_info) + online_count = len(self.integrated_system.substations) - len(failed_list) + total_count = len(self.integrated_system.substations) + + # EV stations + ev_online = sum(1 for ev in self.integrated_system.ev_stations.values() + if ev.get('operational', True)) + ev_total = len(self.integrated_system.ev_stations) + + # V2G status + try: + v2g_status = self.v2g_manager.get_v2g_status() + v2g_sessions = v2g_status.get('active_sessions', 0) + v2g_capacity = v2g_status.get('total_v2g_capacity_kw', 0) + except Exception: + v2g_sessions = 0 + v2g_capacity = 0 + + # Simulation state + sumo_running = self.system_state.get('sumo_running', False) + sim_speed = self.system_state.get('simulation_speed', 1.0) + + # Scenario state (time, temperature) + time_str = "N/A" + temp_str = "N/A" + if self.scenario_controller: + try: + time_info = self.scenario_controller.get_current_time() + time_str = time_info.get('formatted', 'N/A') + temp_str = f"{self.scenario_controller.current_temperature:.0f}Β°F" + except Exception: + pass + + # Vehicle stats + vehicle_text = "Simulation not running" + if sumo_running: + try: + vehicles = self.integrated_system.get_network_state().get('vehicles', []) + if not vehicles and hasattr(self.tool_executor.sumo_manager, 'get_vehicle_positions_for_visualization'): + vehicles = self.tool_executor.sumo_manager.get_vehicle_positions_for_visualization() + total_v = len(vehicles) if vehicles else 0 + evs = sum(1 for v in vehicles if v.get('is_ev')) if vehicles else 0 + charging = sum(1 for v in vehicles if v.get('is_charging')) if vehicles else 0 + vehicle_text = f"{total_v} vehicles ({evs} EVs, {charging} charging)" + except Exception: + vehicle_text = "Running (stats unavailable)" + + # --- Build the prompt --- + return f"""You are the Manhattan Power Grid AI Controller β€” an expert system managing NYC's electrical infrastructure. + +═══════════════════════════════════════════════ +πŸ“Š LIVE SYSTEM STATE (updated every request) +═══════════════════════════════════════════════ +Substations: {online_count}/{total_count} online | Total Load: {total_load:.1f} MW +{substations_text} + +EV Stations: {ev_online}/{ev_total} operational +V2G Sessions: {v2g_sessions} active | Capacity: {v2g_capacity:.0f} kW +Vehicles: {vehicle_text} +Simulation: {"🟒 Running" if sumo_running else "⚫ Stopped"} at {sim_speed}x speed +Time: {time_str} | Temperature: {temp_str} +{"⚠️ FAILED SUBSTATIONS: " + ", ".join(failed_list) if failed_list else "βœ… All substations operational"} +═══════════════════════════════════════════════ + +CAPABILITIES: +You have full control over the Manhattan power grid through the tools provided. +You can control substations, manage V2G, run simulations, set time/temperature, +focus the map, and query system status. + +RULES: +1. Use tools for ALL actions β€” never pretend to execute commands without tools +2. For multi-step requests, call tools in sequence and reason about results +3. Report results clearly β€” what changed, what was affected, current state +4. If a tool fails, explain why and suggest alternatives +5. For status queries, use get_system_status or get_scenario_status tools +6. Be concise but thorough β€” include numbers and specifics +7. For ambiguous substation names, try to fuzzy-match (e.g. "times sq" β†’ "Times Square") +8. When the user asks about the system, use tools to get fresh data rather than guessing +""" + + # ========================================================================= + # MAIN CHAT METHOD β€” THE AGENTIC LOOP + # ========================================================================= + + async def chat(self, user_input: str, user_id: str = 'web_user') -> dict: + """ + Main entry point. Sends user message to OpenAI with tools, + executes any tool calls, feeds results back, repeats until done. + """ + if not self.client: + return { + "success": False, + "text": "OpenAI API key not configured. Please set OPENAI_API_KEY.", + "fallback": True + } + + try: + # 1. Build messages with dynamic system prompt + system_prompt = self._build_system_prompt() + messages = [ + {"role": "system", "content": system_prompt}, + ] + + # Add conversation history (last N messages) + if self.conversation_history: + messages.extend(self.conversation_history[-self.max_history:]) + + # Add current user message + messages.append({"role": "user", "content": user_input}) + + # 2. Call OpenAI with tools + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=self.tools, + temperature=0.3, + ) + + # 3. AGENTIC LOOP β€” keep going while LLM wants to call tools + tool_results = [] + all_map_actions = [] + iteration = 0 + + while (response.choices[0].finish_reason == "tool_calls" and + iteration < self.max_iterations): + + assistant_msg = response.choices[0].message + # Add assistant's tool_call message to conversation + messages.append({ + "role": "assistant", + "content": assistant_msg.content, + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments + } + } + for tc in assistant_msg.tool_calls + ] + }) + + # Execute each tool call + for tool_call in assistant_msg.tool_calls: + tool_name = tool_call.function.name + try: + tool_args = json.loads(tool_call.function.arguments) + except json.JSONDecodeError: + tool_args = {} + + print(f"[AGENTIC] Iteration {iteration + 1}: Calling {tool_name}({tool_args})") + + # Execute the tool + result = self.tool_executor.execute(tool_name, tool_args) + + # Track results + tool_results.append({ + "tool": tool_name, + "args": tool_args, + "result": result, + "iteration": iteration + 1 + }) + + # Extract map actions + if result.get("map_action"): + all_map_actions.append(result["map_action"]) + + # Emit Socket.IO events for frontend UI sync + if self.socketio: + self._emit_ui_updates(tool_name, tool_args, result) + + # Add tool result to messages for next LLM call + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(result, default=str) + }) + + # Call OpenAI again with tool results + response = self.client.chat.completions.create( + model=self.model, + messages=messages, + tools=self.tools, + temperature=0.3, + ) + iteration += 1 + + # 4. Get final text response + final_text = response.choices[0].message.content or "Action completed." + + # 5. Update conversation history + self.conversation_history.append({"role": "user", "content": user_input}) + self.conversation_history.append({"role": "assistant", "content": final_text}) + + # Trim history + if len(self.conversation_history) > self.max_history * 2: + self.conversation_history = self.conversation_history[-self.max_history:] + + # 6. Build response + response_data = { + "success": True, + "text": final_text, + "tool_calls_made": [ + {"tool": t["tool"], "args": t["args"], "success": t["result"].get("success", False)} + for t in tool_results + ], + "tools_called": len(tool_results), + "iterations": iteration, + "backend_executed": len(tool_results) > 0, + "map_action": all_map_actions[0] if all_map_actions else None, + "map_actions": all_map_actions, + "timestamp": datetime.now().isoformat() + } + + print(f"[AGENTIC] Complete: {len(tool_results)} tools called in {iteration} iterations") + return response_data + + except Exception as e: + print(f"[AGENTIC] Error: {e}") + traceback.print_exc() + return { + "success": False, + "text": f"I encountered an error: {str(e)}", + "fallback": True, + "error": str(e) + } + + # ========================================================================= + # SOCKET.IO β€” Frontend UI Sync + # ========================================================================= + + def _emit_ui_updates(self, tool_name: str, tool_args: dict, result: dict): + """Emit Socket.IO events so the frontend UI updates in real-time.""" + if not self.socketio: + return + + try: + if tool_name == "set_time" and result.get("success"): + self.socketio.emit('scenario_time_update', { + 'hour': result.get('hour', tool_args.get('hour')), + 'minute': result.get('minute', 0) + }) + + elif tool_name == "set_temperature" and result.get("success"): + self.socketio.emit('scenario_temp_update', { + 'temperature': result.get('temperature', tool_args.get('temperature')) + }) + + elif tool_name == "set_simulation_speed" and result.get("success"): + self.socketio.emit('simulation_speed_update', { + 'speed': result.get('speed', tool_args.get('speed')) + }) + + elif tool_name in ("fail_substation", "restore_substation") and result.get("success"): + self.socketio.emit('substation_update', { + 'substation': result.get('substation'), + 'action': result.get('action'), + 'operational': tool_name == "restore_substation" + }) + + elif tool_name == "restore_all_substations" and result.get("success"): + self.socketio.emit('substation_update', { + 'action': 'restore_all', + 'restored': result.get('restored', []) + }) + + elif tool_name in ("enable_v2g", "disable_v2g") and result.get("success"): + self.socketio.emit('v2g_update', { + 'substation': result.get('substation'), + 'action': result.get('action'), + }) + + elif tool_name in ("start_simulation", "stop_simulation") and result.get("success"): + self.socketio.emit('simulation_state_update', { + 'running': tool_name == "start_simulation", + }) + + elif tool_name == "focus_map" and result.get("success"): + map_action = result.get('map_action', {}) + self.socketio.emit('ai_map_focus', map_action) + + elif tool_name == "run_scenario" and result.get("success"): + self.socketio.emit('scenario_change', { + 'scenario': tool_args.get('scenario'), + 'result': {k: v for k, v in result.items() + if k in ('success', 'scenario', 'message')} + }) + + except Exception as e: + print(f"[AGENTIC] Socket.IO emit error: {e}") + + # ========================================================================= + # UTILITY METHODS + # ========================================================================= + + def clear_history(self): + """Clear conversation history.""" + self.conversation_history = [] + + def get_tool_count(self) -> int: + """Return number of available tools.""" + return len(self.tools) + + def is_available(self) -> bool: + """Check if the agentic chatbot is operational.""" + return self.client is not None + + @staticmethod + def _detect_local_model(base_url: str) -> str: + """Auto-detect the model name from a local OpenAI-compatible server.""" + import requests + try: + # Strip /v1 suffix if present, then query /v1/models + url = base_url.rstrip('/') + if not url.endswith('/v1'): + url += '/v1' + resp = requests.get(f"{url}/models", timeout=3) + if resp.ok: + data = resp.json() + models = data.get('data', []) + if models: + model_id = models[0].get('id', 'default') + print(f"[AGENTIC CHATBOT] Auto-detected local model: {model_id}") + return model_id + except Exception as e: + print(f"[AGENTIC CHATBOT] Could not detect local model: {e}") + return "default" diff --git a/agentic_tools.py b/agentic_tools.py new file mode 100644 index 0000000..0216b17 --- /dev/null +++ b/agentic_tools.py @@ -0,0 +1,1007 @@ +""" +Agentic Tools Module β€” OpenAI Function Calling Definitions + Executor + +This module defines: +1. TOOL_DEFINITIONS: JSON schemas for OpenAI's function calling API +2. ToolExecutor: Maps tool names to actual system method calls + +The LLM sees the schemas and decides which tools to call. +Your code uses ToolExecutor to execute the calls and return results. +""" + +import json +import traceback +from datetime import datetime +from typing import Dict, Any, Optional + + +# ============================================================================= +# 1. TOOL DEFINITIONS β€” OpenAI Function Calling Schemas +# ============================================================================= + +TOOL_DEFINITIONS = [ + # ------------------------------------------------------------------------- + # SUBSTATION CONTROL + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "fail_substation", + "description": "Simulate a failure at a substation, taking it offline. This causes cascading effects: connected traffic lights go to caution mode and EV charging stations lose power. Use this to test grid resilience.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "Name of the substation to fail (e.g. 'Times Square', 'Penn Station')" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "restore_substation", + "description": "Restore a failed substation back to operational status. This brings connected traffic lights and EV stations back online, and automatically disables any active V2G sessions for that substation.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "Name of the substation to restore" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "restore_all_substations", + "description": "Restore ALL failed substations at once. Use when multiple substations are down and the user wants to reset the grid.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + + # ------------------------------------------------------------------------- + # V2G (Vehicle-to-Grid) CONTROL + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "enable_v2g", + "description": "Enable Vehicle-to-Grid (V2G) energy trading for a FAILED substation. EVs near the substation will feed power back to the grid to partially restore service. The substation must be failed/offline for V2G to be activated.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "Name of the failed substation to enable V2G for" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "disable_v2g", + "description": "Disable V2G energy trading for a substation and release all participating vehicles.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "Name of the substation to disable V2G for" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_v2g_status", + "description": "Get the current V2G system status including active sessions, total capacity, energy delivered, and participating vehicles.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + + # ------------------------------------------------------------------------- + # SIMULATION CONTROL (SUMO) + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "start_simulation", + "description": "Start the SUMO traffic simulation with vehicles on Manhattan streets. You can specify the number of vehicles and EV percentage.", + "parameters": { + "type": "object", + "properties": { + "vehicle_count": { + "type": "integer", + "description": "Number of vehicles to spawn (default: 50, max: 500)", + "default": 50 + }, + "ev_percentage": { + "type": "integer", + "description": "Percentage of vehicles that are electric (0-100, default: 70)", + "default": 70 + } + }, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "stop_simulation", + "description": "Stop the SUMO traffic simulation. All vehicles will be removed from the map.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "spawn_vehicles", + "description": "Spawn additional vehicles into the running simulation. The simulation must be running first.", + "parameters": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "description": "Number of additional vehicles to spawn (default: 50)", + "default": 50 + } + }, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "set_simulation_speed", + "description": "Set the simulation speed multiplier. 1.0 = real-time, 2.0 = 2x speed, 0.5 = half speed. Range: 0.1 to 10.0.", + "parameters": { + "type": "object", + "properties": { + "speed": { + "type": "number", + "description": "Speed multiplier (0.1 to 10.0)" + } + }, + "required": ["speed"] + } + } + }, + + # ------------------------------------------------------------------------- + # SCENARIO CONTROL (Time, Temperature, Scenarios) + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "set_time", + "description": "Set the simulation time of day. This affects power load patterns (morning rush, evening peak, etc.), lighting, and grid stress levels.", + "parameters": { + "type": "object", + "properties": { + "hour": { + "type": "number", + "description": "Hour of day (0-23, e.g. 8 for 8 AM, 20 for 8 PM)" + }, + "minute": { + "type": "integer", + "description": "Minute (0-59, default: 0)", + "default": 0 + } + }, + "required": ["hour"] + } + } + }, + { + "type": "function", + "function": { + "name": "set_temperature", + "description": "Set the ambient temperature in Fahrenheit. Extreme temperatures (>90Β°F or <30Β°F) increase grid load due to AC/heating demand and can trigger substation overloads.", + "parameters": { + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in degrees Fahrenheit (e.g. 72 for normal, 98 for heatwave, 15 for extreme cold)" + } + }, + "required": ["temperature"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_scenario", + "description": "Run a predefined test scenario that sets time, temperature, and vehicle count to simulate specific conditions.", + "parameters": { + "type": "object", + "properties": { + "scenario": { + "type": "string", + "enum": [ + "rush_hour_stress_test", + "evening_peak_v2g", + "winter_emergency", + "summer_heatwave", + "late_night_low_load" + ], + "description": "Name of the scenario to run" + } + }, + "required": ["scenario"] + } + } + }, + + # ------------------------------------------------------------------------- + # EV CONFIGURATION + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "configure_ev", + "description": "Configure EV (Electric Vehicle) parameters for the simulation: what percentage of vehicles are electric and their battery SOC (State of Charge) range.", + "parameters": { + "type": "object", + "properties": { + "ev_percentage": { + "type": "integer", + "description": "Percentage of vehicles that are electric (0-100)", + "default": 70 + }, + "battery_min_soc": { + "type": "integer", + "description": "Minimum battery state of charge percentage (1-100)", + "default": 20 + }, + "battery_max_soc": { + "type": "integer", + "description": "Maximum battery state of charge percentage (1-100)", + "default": 90 + } + }, + "required": [] + } + } + }, + + # ------------------------------------------------------------------------- + # SYSTEM STATUS & QUERIES + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "get_system_status", + "description": "Get comprehensive system status: substations (online/offline, load levels), SUMO simulation state, vehicle counts, and scenario info.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "get_scenario_status", + "description": "Get current scenario state including time of day, temperature, load levels per substation, and any active warnings or failures.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "get_substation_details", + "description": "Get detailed information about a specific substation including load level, capacity, operational status, connected EV stations, and traffic lights.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "Name of the substation to get details for" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "get_load_forecast", + "description": "Get a load forecast for the next N hours showing predicted power demand across all substations.", + "parameters": { + "type": "object", + "properties": { + "hours": { + "type": "integer", + "description": "Number of hours to forecast (default: 6)", + "default": 6 + } + }, + "required": [] + } + } + }, + + # ------------------------------------------------------------------------- + # MAP CONTROL + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "focus_map", + "description": "Focus the map view on a specific location, substation, or EV station. The map will fly to and highlight the location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "Location name to focus on (e.g. 'Times Square', 'Penn Station', 'Central Park', 'Grand Central')" + } + }, + "required": ["location"] + } + } + }, + + # ------------------------------------------------------------------------- + # TEST SCENARIOS + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "run_ev_rush_test", + "description": "Run an EV rush hour test: spawns many low-battery EVs to stress the charging infrastructure. Requires SUMO to be running.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "run_v2g_test", + "description": "Run a complete V2G test scenario: fails a substation, enables V2G, and monitors the restoration process.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, +] + + +# ============================================================================= +# 2. TOOL EXECUTOR β€” Maps tool names to actual system calls +# ============================================================================= + +class ToolExecutor: + """ + Executes tool calls by mapping tool names to internal system methods. + All calls are direct Python method invocations (same process, no HTTP). + """ + + def __init__(self, integrated_system, v2g_manager, sumo_manager, + power_grid, system_state, scenario_controller=None, + current_ev_config=None, vehicle_spawn_queue=None): + self.integrated_system = integrated_system + self.v2g_manager = v2g_manager + self.sumo_manager = sumo_manager + self.power_grid = power_grid + self.system_state = system_state + self.scenario_controller = scenario_controller + self.current_ev_config = current_ev_config or {} + self.vehicle_spawn_queue = vehicle_spawn_queue + + # Map tool names β†’ handler methods + self.handlers = { + # Substation Control + "fail_substation": self._fail_substation, + "restore_substation": self._restore_substation, + "restore_all_substations": self._restore_all_substations, + # V2G Control + "enable_v2g": self._enable_v2g, + "disable_v2g": self._disable_v2g, + "get_v2g_status": self._get_v2g_status, + # Simulation Control + "start_simulation": self._start_simulation, + "stop_simulation": self._stop_simulation, + "spawn_vehicles": self._spawn_vehicles, + "set_simulation_speed": self._set_simulation_speed, + # Scenario Control + "set_time": self._set_time, + "set_temperature": self._set_temperature, + "run_scenario": self._run_scenario, + # EV Configuration + "configure_ev": self._configure_ev, + # Status & Queries + "get_system_status": self._get_system_status, + "get_scenario_status": self._get_scenario_status, + "get_substation_details": self._get_substation_details, + "get_load_forecast": self._get_load_forecast, + # Map Control + "focus_map": self._focus_map, + # Test Scenarios + "run_ev_rush_test": self._run_ev_rush_test, + "run_v2g_test": self._run_v2g_test, + } + + def execute(self, tool_name: str, arguments: dict) -> dict: + """Execute a tool by name. Returns a result dict.""" + handler = self.handlers.get(tool_name) + if not handler: + return {"success": False, "error": f"Unknown tool: {tool_name}"} + try: + result = handler(**arguments) + print(f"[TOOL EXECUTOR] {tool_name}({arguments}) β†’ success={result.get('success', 'N/A')}") + return result + except Exception as e: + print(f"[TOOL EXECUTOR] {tool_name}({arguments}) β†’ ERROR: {e}") + traceback.print_exc() + return {"success": False, "error": str(e)} + + # ========================================================================= + # SUBSTATION CONTROL + # ========================================================================= + + def _fail_substation(self, substation: str) -> dict: + """Take a substation offline β€” mirrors /api/fail/""" + # Validate + if substation not in self.integrated_system.substations: + available = list(self.integrated_system.substations.keys()) + return {"success": False, "error": f"Substation '{substation}' not found. Available: {available}"} + + sub_data = self.integrated_system.substations[substation] + if not sub_data.get('operational', True): + return {"success": False, "error": f"{substation} is already offline"} + + # Execute failure + impact = self.integrated_system.simulate_substation_failure(substation) + self.power_grid.trigger_failure('substation', substation) + + # Update SUMO traffic lights if running + if self.system_state.get('sumo_running') and self.sumo_manager.running: + self.sumo_manager.update_traffic_lights() + if hasattr(self.sumo_manager, 'handle_blackout_traffic_lights'): + self.sumo_manager.handle_blackout_traffic_lights([substation]) + + # Update affected EV stations + for ev_id, ev_station in self.integrated_system.ev_stations.items(): + if ev_station['substation'] == substation: + ev_station['operational'] = False + if ev_id in getattr(self.sumo_manager, 'ev_stations_sumo', {}): + self.sumo_manager.ev_stations_sumo[ev_id]['available'] = 0 + if hasattr(self.sumo_manager, 'station_manager') and self.sumo_manager.station_manager: + if ev_id in self.sumo_manager.station_manager.stations: + self.sumo_manager.station_manager.stations[ev_id]['operational'] = False + self.sumo_manager.station_manager.handle_blackout(substation) + + return { + "success": True, + "substation": substation, + "action": "failed", + "traffic_lights_affected": impact.get('traffic_lights_affected', 0), + "ev_stations_affected": impact.get('ev_stations_affected', 0), + "load_lost_mw": impact.get('load_lost_mw', 0), + "map_action": { + "type": "highlight_failure", + "location": substation, + "coords": sub_data.get('coords', sub_data.get('location', [])) + } + } + + def _restore_substation(self, substation: str) -> dict: + """Restore a substation β€” mirrors /api/restore/""" + if substation not in self.integrated_system.substations: + available = list(self.integrated_system.substations.keys()) + return {"success": False, "error": f"Substation '{substation}' not found. Available: {available}"} + + sub_data = self.integrated_system.substations[substation] + if sub_data.get('operational', True): + return {"success": False, "error": f"{substation} is already operational"} + + success = self.integrated_system.restore_substation(substation) + if not success: + return {"success": False, "error": f"Failed to restore {substation}"} + + self.power_grid.restore_component('substation', substation) + self.v2g_manager.disable_v2g_for_substation(substation) + + lights_restored = 0 + ev_stations_restored = 0 + + if self.system_state.get('sumo_running') and self.sumo_manager.running: + self.sumo_manager.update_traffic_lights() + for ev_id, ev_station in self.integrated_system.ev_stations.items(): + if ev_station['substation'] == substation: + ev_station['operational'] = True + ev_stations_restored += 1 + if ev_id in getattr(self.sumo_manager, 'ev_stations_sumo', {}): + self.sumo_manager.ev_stations_sumo[ev_id]['available'] = ev_station['chargers'] + + return { + "success": True, + "substation": substation, + "action": "restored", + "lights_restored": lights_restored, + "ev_stations_restored": ev_stations_restored, + "map_action": { + "type": "highlight_restore", + "location": substation, + "coords": sub_data.get('coords', sub_data.get('location', [])) + } + } + + def _restore_all_substations(self) -> dict: + """Restore all failed substations""" + restored = [] + already_online = [] + for name, sub_data in self.integrated_system.substations.items(): + if not sub_data.get('operational', True): + result = self._restore_substation(name) + if result.get('success'): + restored.append(name) + else: + already_online.append(name) + + if not restored: + return {"success": True, "message": "All substations are already online", "restored": []} + + return { + "success": True, + "restored": restored, + "count": len(restored), + "already_online": already_online + } + + # ========================================================================= + # V2G CONTROL + # ========================================================================= + + def _enable_v2g(self, substation: str) -> dict: + """Enable V2G for a failed substation""" + if substation not in self.integrated_system.substations: + return {"success": False, "error": f"Substation '{substation}' not found"} + + sub_data = self.integrated_system.substations[substation] + if sub_data.get('operational', True): + return {"success": False, "error": f"{substation} is operational β€” V2G is only for failed substations"} + + success = self.v2g_manager.enable_v2g_for_substation(substation) + if not success: + return {"success": False, "error": f"Failed to enable V2G for {substation}"} + + power_needed = sub_data.get('load_mw', 0) + rate = self.v2g_manager.get_current_rate(substation) + energy_needed = self.v2g_manager.substation_energy_required.get(substation, 50) + + return { + "success": True, + "substation": substation, + "action": "v2g_enabled", + "power_needed_mw": power_needed, + "energy_needed_kwh": energy_needed, + "rate_per_kwh": rate, + "vehicles_needed": max(2, int(energy_needed / 30) + 1) + } + + def _disable_v2g(self, substation: str) -> dict: + """Disable V2G for a substation""" + if substation not in self.integrated_system.substations: + return {"success": False, "error": f"Substation '{substation}' not found"} + + self.v2g_manager.disable_v2g_for_substation(substation) + return {"success": True, "substation": substation, "action": "v2g_disabled"} + + def _get_v2g_status(self) -> dict: + """Get V2G dashboard data""" + try: + v2g_data = self.v2g_manager.get_v2g_dashboard_data() + status = self.v2g_manager.get_v2g_status() + return { + "success": True, + "active_sessions": status.get('active_sessions', 0), + "total_v2g_capacity_kw": status.get('total_v2g_capacity_kw', 0), + "total_energy_delivered_kwh": status.get('total_energy_delivered_kwh', 0), + "substations_with_v2g": status.get('substations_with_v2g', []), + "dashboard": v2g_data + } + except Exception as e: + return {"success": False, "error": str(e)} + + # ========================================================================= + # SIMULATION CONTROL + # ========================================================================= + + def _start_simulation(self, vehicle_count: int = 50, ev_percentage: int = 70) -> dict: + """Start SUMO traffic simulation""" + if self.system_state.get('sumo_running'): + return {"success": False, "error": "Simulation is already running"} + + try: + vehicle_count = max(1, min(500, vehicle_count)) + ev_pct = max(0, min(100, ev_percentage)) / 100.0 + + success = self.sumo_manager.start( + vehicle_count=vehicle_count, + ev_percentage=ev_pct + ) + + if success: + self.system_state['sumo_running'] = True + return { + "success": True, + "vehicle_count": vehicle_count, + "ev_percentage": ev_percentage, + "message": f"Simulation started with {vehicle_count} vehicles ({ev_percentage}% electric)" + } + else: + return {"success": False, "error": "SUMO failed to start"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _stop_simulation(self) -> dict: + """Stop SUMO simulation""" + if not self.system_state.get('sumo_running'): + return {"success": False, "error": "Simulation is not running"} + + try: + self.sumo_manager.stop() + self.system_state['sumo_running'] = False + return {"success": True, "message": "Simulation stopped"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _spawn_vehicles(self, count: int = 50) -> dict: + """Spawn additional vehicles into running simulation""" + if not self.system_state.get('sumo_running'): + return {"success": False, "error": "Simulation is not running. Start it first."} + + count = max(1, min(500, count)) + + # Use the spawn queue (async, non-blocking) + if self.vehicle_spawn_queue is not None: + self.vehicle_spawn_queue.append({ + 'count': count, + 'timestamp': datetime.now().isoformat() + }) + return {"success": True, "count": count, "message": f"Queued {count} vehicles for spawning"} + else: + # Direct spawn + try: + self.sumo_manager.spawn_vehicles(count) + return {"success": True, "count": count, "message": f"Spawned {count} vehicles"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _set_simulation_speed(self, speed: float) -> dict: + """Set simulation speed multiplier""" + speed = max(0.1, min(10.0, speed)) + self.system_state['simulation_speed'] = speed + return {"success": True, "speed": speed, "message": f"Simulation speed set to {speed}x"} + + # ========================================================================= + # SCENARIO CONTROL + # ========================================================================= + + def _set_time(self, hour: float, minute: int = 0) -> dict: + """Set simulation time of day""" + if not self.scenario_controller: + return {"success": False, "error": "Scenario controller not available"} + + try: + hour = max(0, min(23.99, hour)) + minute = max(0, min(59, minute)) + result = self.scenario_controller.set_time(hour, minute, 0) + return { + "success": True, + "hour": hour, + "minute": minute, + "time_description": result.get('description', f"{int(hour):02d}:{minute:02d}"), + **{k: v for k, v in result.items() if k != 'description'} + } + except Exception as e: + return {"success": False, "error": str(e)} + + def _set_temperature(self, temperature: float) -> dict: + """Set ambient temperature""" + if not self.scenario_controller: + return {"success": False, "error": "Scenario controller not available"} + + try: + result = self.scenario_controller.set_temperature(temperature) + warning = None + if temperature > 90: + warning = "High temperature! AC demand will increase grid load." + elif temperature < 30: + warning = "Low temperature! Heating demand will increase grid load." + + return { + "success": True, + "temperature": temperature, + "warning": warning, + **result + } + except Exception as e: + return {"success": False, "error": str(e)} + + def _run_scenario(self, scenario: str) -> dict: + """Run a predefined test scenario""" + if not self.scenario_controller: + return {"success": False, "error": "Scenario controller not available"} + + try: + result = self.scenario_controller.run_scenario(scenario) + return {"success": True, "scenario": scenario, **result} + except Exception as e: + return {"success": False, "error": str(e)} + + # ========================================================================= + # EV CONFIGURATION + # ========================================================================= + + def _configure_ev(self, ev_percentage: int = 70, + battery_min_soc: int = 20, + battery_max_soc: int = 90) -> dict: + """Configure EV parameters""" + ev_percentage = max(0, min(100, ev_percentage)) + battery_min_soc = max(1, min(100, battery_min_soc)) + battery_max_soc = max(1, min(100, battery_max_soc)) + if battery_min_soc >= battery_max_soc: + battery_min_soc = battery_max_soc - 1 + + # Update global config + self.current_ev_config.update({ + 'ev_percentage': ev_percentage, + 'battery_min_soc': battery_min_soc, + 'battery_max_soc': battery_max_soc, + 'updated_at': datetime.now().isoformat() + }) + + # Update SUMO manager if running + if self.sumo_manager and self.sumo_manager.running: + self.sumo_manager.ev_percentage = ev_percentage / 100 + self.sumo_manager.battery_min_soc = battery_min_soc / 100 + self.sumo_manager.battery_max_soc = battery_max_soc / 100 + + return { + "success": True, + "ev_percentage": ev_percentage, + "battery_min_soc": battery_min_soc, + "battery_max_soc": battery_max_soc, + } + + # ========================================================================= + # STATUS & QUERIES + # ========================================================================= + + def _get_system_status(self) -> dict: + """Get comprehensive system status""" + try: + substations = {} + failed_list = [] + for name, sub in self.integrated_system.substations.items(): + operational = sub.get('operational', True) + substations[name] = { + "operational": operational, + "load_mw": round(sub.get('load_mw', 0), 1), + "capacity_mva": sub.get('capacity_mva', 0), + } + if not operational: + failed_list.append(name) + + vehicle_stats = {} + if self.system_state.get('sumo_running') and self.sumo_manager.running: + vehicles = self.sumo_manager.get_vehicle_positions_for_visualization() + vehicle_stats = { + "total": len(vehicles), + "evs": sum(1 for v in vehicles if v.get('is_ev')), + "charging": sum(1 for v in vehicles if v.get('is_charging')), + } + + return { + "success": True, + "substations_online": len(self.integrated_system.substations) - len(failed_list), + "substations_total": len(self.integrated_system.substations), + "failed_substations": failed_list, + "substations": substations, + "sumo_running": self.system_state.get('sumo_running', False), + "simulation_speed": self.system_state.get('simulation_speed', 1.0), + "vehicles": vehicle_stats, + "ev_stations": len(self.integrated_system.ev_stations), + } + except Exception as e: + return {"success": False, "error": str(e)} + + def _get_scenario_status(self) -> dict: + """Get current scenario state""" + if not self.scenario_controller: + return {"success": False, "error": "Scenario controller not available"} + try: + status = self.scenario_controller.get_system_status() + return {"success": True, **status} + except Exception as e: + return {"success": False, "error": str(e)} + + def _get_substation_details(self, substation: str) -> dict: + """Get detailed info about a specific substation""" + if substation not in self.integrated_system.substations: + available = list(self.integrated_system.substations.keys()) + return {"success": False, "error": f"Substation '{substation}' not found. Available: {available}"} + + sub = self.integrated_system.substations[substation] + + # Find connected EV stations + connected_ev = [] + for ev_id, ev_data in self.integrated_system.ev_stations.items(): + if ev_data.get('substation') == substation: + connected_ev.append({ + "id": ev_id, + "name": ev_data.get('name', ev_id), + "operational": ev_data.get('operational', True), + "chargers": ev_data.get('chargers', 0) + }) + + # Find connected traffic lights + connected_lights = sum( + 1 for tl in self.integrated_system.traffic_lights.values() + if tl.get('substation') == substation + ) + + return { + "success": True, + "name": substation, + "operational": sub.get('operational', True), + "load_mw": round(sub.get('load_mw', 0), 1), + "capacity_mva": sub.get('capacity_mva', 0), + "location": sub.get('coords', sub.get('location', [])), + "connected_ev_stations": connected_ev, + "connected_traffic_lights": connected_lights, + "v2g_active": substation in self.v2g_manager.get_v2g_status().get('substations_with_v2g', []) + } + + def _get_load_forecast(self, hours: int = 6) -> dict: + """Get load forecast""" + if not self.scenario_controller: + return {"success": False, "error": "Scenario controller not available"} + try: + forecast = self.scenario_controller.get_load_forecast(hours) + return {"success": True, "hours": hours, "forecast": forecast} + except Exception as e: + return {"success": False, "error": str(e)} + + # ========================================================================= + # MAP CONTROL + # ========================================================================= + + # Known locations for map focus + LOCATIONS = { + 'times square': {'name': 'Times Square', 'coords': [-73.9857, 40.7580], 'zoom': 17}, + 'penn station': {'name': 'Penn Station', 'coords': [-73.9937, 40.7505], 'zoom': 17}, + 'grand central': {'name': 'Grand Central', 'coords': [-73.9772, 40.7527], 'zoom': 17}, + 'columbus circle': {'name': 'Columbus Circle', 'coords': [-73.9819, 40.7681], 'zoom': 17}, + 'union square': {'name': 'Union Square', 'coords': [-73.9903, 40.7359], 'zoom': 17}, + 'washington square': {'name': 'Washington Square', 'coords': [-73.9973, 40.7308], 'zoom': 17}, + 'brooklyn bridge': {'name': 'Brooklyn Bridge', 'coords': [-73.9969, 40.7061], 'zoom': 16}, + 'wall street': {'name': 'Wall Street', 'coords': [-74.0090, 40.7068], 'zoom': 17}, + 'central park': {'name': 'Central Park', 'coords': [-73.9654, 40.7829], 'zoom': 14}, + 'manhattan': {'name': 'Manhattan', 'coords': [-73.9712, 40.7831], 'zoom': 12}, + 'murray hill': {'name': 'Murray Hill', 'coords': [-73.9785, 40.7488], 'zoom': 17}, + 'turtle bay': {'name': 'Turtle Bay', 'coords': [-73.9680, 40.7527], 'zoom': 17}, + 'hells kitchen': {"name": "Hell's Kitchen", 'coords': [-73.9934, 40.7638], 'zoom': 17}, + "hell's kitchen": {"name": "Hell's Kitchen", 'coords': [-73.9934, 40.7638], 'zoom': 17}, + 'midtown east': {'name': 'Midtown East', 'coords': [-73.9712, 40.7551], 'zoom': 17}, + 'midtown': {'name': 'Midtown', 'coords': [-73.9845, 40.7549], 'zoom': 15}, + 'upper east': {'name': 'Upper East Side', 'coords': [-73.9565, 40.7736], 'zoom': 15}, + } + + def _focus_map(self, location: str) -> dict: + """Focus map on a location""" + loc_key = location.lower().strip() + loc_data = self.LOCATIONS.get(loc_key) + + # Also check substations + if not loc_data: + for name, sub in self.integrated_system.substations.items(): + if name.lower() == loc_key or loc_key in name.lower(): + coords = sub.get('coords', sub.get('location', [])) + loc_data = {'name': name, 'coords': coords, 'zoom': 17} + break + + if not loc_data: + available = list(self.LOCATIONS.keys()) + list(self.integrated_system.substations.keys()) + return {"success": False, "error": f"Location '{location}' not found. Try: {', '.join(available[:10])}"} + + return { + "success": True, + "location": loc_data['name'], + "map_action": { + "type": "focus_and_highlight", + "location": loc_data['name'], + "name": loc_data['name'], + "coordinates": loc_data['coords'], + "zoom": loc_data.get('zoom', 17), + "highlight": True, + "showConnections": True + } + } + + # ========================================================================= + # TEST SCENARIOS + # ========================================================================= + + def _run_ev_rush_test(self) -> dict: + """Run EV rush hour test""" + if not self.system_state.get('sumo_running'): + return {"success": False, "error": "Simulation must be running first"} + try: + count = 30 + if self.vehicle_spawn_queue is not None: + self.vehicle_spawn_queue.append({'count': count, 'force_low_battery': True}) + return {"success": True, "message": f"EV rush test started β€” spawning {count} low-battery EVs"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _run_v2g_test(self) -> dict: + """Run V2G test scenario""" + # Pick a substation to fail + target = None + for name, sub in self.integrated_system.substations.items(): + if sub.get('operational', True): + target = name + break + + if not target: + return {"success": False, "error": "No operational substations to test with"} + + # Fail it + fail_result = self._fail_substation(target) + if not fail_result.get('success'): + return fail_result + + # Enable V2G + v2g_result = self._enable_v2g(target) + + return { + "success": True, + "test_substation": target, + "failure_result": fail_result, + "v2g_result": v2g_result, + "message": f"V2G test started: {target} failed and V2G enabled. Monitor V2G status for restoration progress." + } diff --git a/main_complete_integration.py b/main_complete_integration.py index 8939d7a..d3b4b27 100644 --- a/main_complete_integration.py +++ b/main_complete_integration.py @@ -295,6 +295,36 @@ def broadcast_state(scenario_status): load_model = None scenario_controller = None +# Initialize AGENTIC CHATBOT β€” OpenAI function calling with tool executor +try: + from agentic_tools import ToolExecutor + from agentic_chatbot import AgenticChatbot + + tool_executor = ToolExecutor( + integrated_system=integrated_system, + v2g_manager=v2g_manager, + sumo_manager=sumo_manager, + power_grid=power_grid, + system_state=system_state, + scenario_controller=scenario_controller, + current_ev_config=current_ev_config if 'current_ev_config' in dir() else {}, + vehicle_spawn_queue=vehicle_spawn_queue + ) + agentic_chatbot = AgenticChatbot( + tool_executor=tool_executor, + integrated_system=integrated_system, + v2g_manager=v2g_manager, + system_state=system_state, + socketio=socketio, + scenario_controller=scenario_controller + ) + print(f"βœ“ AGENTIC CHATBOT INITIALIZED β€” {agentic_chatbot.get_tool_count()} tools available") +except Exception as e: + print(f"Agentic Chatbot not available: {e}") + import traceback + traceback.print_exc() + agentic_chatbot = None + # Optional: cache of SUMO edge shapes (lon/lat) for road-locked rendering EDGE_SHAPES: dict = {} @@ -385,6 +415,7 @@ def simulation_loop(): print(f"V2G State Update: {V2G_UPDATE}s (V2G session rate)") print("="*70 + "\n") + # BROADCAST OPTIMIZATION BROADCAST_INTERVAL = 5 # Send 1 update per 5 physics steps step_counter = 0 @@ -783,6 +814,13 @@ def index(): """Serve complete dashboard with all features""" return render_template_string(load_html_template()) +@app.route('/api/config') +def get_config(): + """Serve client-side configuration (Mapbox token, etc.) from environment.""" + return jsonify({ + 'mapbox_token': os.environ.get('MAPBOX_TOKEN', ''), + }) + @app.route('/api/debug/buses') def debug_buses(): """Show all bus names in PyPSA""" @@ -1841,78 +1879,75 @@ def ai_predict(): @app.route('/api/ai/chat', methods=['POST']) def ai_chat(): - """WORLD-CLASS AI CHAT - True system control and intelligence.""" + """AI CHAT β€” Agentic tool-calling with fallback chain.""" from datetime import datetime + import asyncio try: body = request.get_json() or {} message = body.get('message', '') - user_id = body.get('user_id', 'system_operator') + user_id = body.get('user_id', 'web_user') if not message: return jsonify({'error': 'Message is required'}), 400 - # FORCE USE ULTRA-INTELLIGENT CHATBOT - PRIORITY #1 (With typo correction) - print(f"[DEBUG] Processing message: {message}") - print(f"[DEBUG] ultra_chatbot available: {ultra_chatbot is not None}") - print(f"[DEBUG] world_class_ai available: {world_class_ai is not None}") + # PRIORITY 1: AGENTIC CHATBOT (OpenAI function calling) + if 'agentic_chatbot' in globals() and agentic_chatbot and agentic_chatbot.is_available(): + print(f"[API /ai/chat] β†’ AGENTIC CHATBOT: {message}") + try: + ai_response = asyncio.run(agentic_chatbot.chat(message, user_id=user_id)) + + # Check if agentic chatbot wants to fall back + if ai_response.get('fallback'): + print(f"[API /ai/chat] Agentic chatbot requested fallback") + raise Exception("Agentic fallback requested") - # TRY ULTRA-INTELLIGENT CHATBOT FIRST (with typo correction and suggestions) - print(f"[API /ai/chat] ultra_chatbot exists: {ultra_chatbot is not None}") + response_text = ai_response.get('text', '') if isinstance(ai_response, dict) else str(ai_response) + return jsonify({ + 'status': 'success', + 'response': response_text, + 'full_data': ai_response + }) + except Exception as e: + print(f"[API /ai/chat] Agentic chatbot error: {e}") + import traceback + traceback.print_exc() + # Fall through to ultra_chatbot + + # PRIORITY 2: ULTRA-INTELLIGENT CHATBOT (rule-based fallback) if ultra_chatbot: - print(f"[DEBUG] βœ… ROUTING TO ULTRA-INTELLIGENT CHATBOT for message: {message}") + print(f"[API /ai/chat] β†’ ULTRA CHATBOT (fallback): {message}") try: - import asyncio - user_id = body.get('user_id', 'web_user') - print(f"[DEBUG] Calling ultra_chatbot.chat() with message='{message}', user_id='{user_id}'") ai_response = asyncio.run(ultra_chatbot.chat(message, user_id=user_id)) - print(f"[DEBUG] Ultra-Intelligent Chatbot SUCCESS: {ai_response}") - - # CRITICAL FIX: Ensure proper response format for scenario-director.js - # Ultra chatbot returns {'text': ...}, but scenario expects {'response': ...} response_text = ai_response.get('text', '') if isinstance(ai_response, dict) else str(ai_response) return jsonify({ 'status': 'success', 'response': response_text, - 'full_data': ai_response # Include full response for debugging + 'full_data': ai_response }) except Exception as e: - print(f"[ERROR] Ultra-Intelligent Chatbot error: {e}") + print(f"[API /ai/chat] Ultra chatbot error: {e}") import traceback traceback.print_exc() - # Fallback to world-class AI if ultra chatbot fails - # FALLBACK TO WORLD-CLASS AI CONTROLLER + # PRIORITY 3: WORLD-CLASS AI CONTROLLER (last resort) if world_class_ai: - print(f"[DEBUG] ROUTING TO WORLD-CLASS AI for message: {message}") + print(f"[API /ai/chat] β†’ WORLD-CLASS AI (last resort): {message}") try: ai_response = world_class_ai.process_intelligent_command(message) - print(f"[DEBUG] World-class AI SUCCESS: {ai_response}") return jsonify(ai_response) except Exception as e: - print(f"[ERROR] World-class AI error: {e}") - import traceback - traceback.print_exc() - # Even if there's an error, still try to use world-class AI fallback + print(f"[API /ai/chat] World-class AI error: {e}") return jsonify({ - 'text': f'[ERROR] Command failed: {str(e)}\\n\\nTry: "Turn off Times Square substation", "Show me Penn Station area", "System status"', + 'text': f'Command failed: {str(e)}', 'type': 'error', - 'intent': 'system_control', - 'confidence': 0.5, - 'system_controlled': False, 'timestamp': datetime.now().isoformat() }) - else: - print(f"[CRITICAL] World-class AI NOT AVAILABLE - this should never happen!") - return jsonify({ - 'text': 'CRITICAL ERROR: World-class AI controller not initialized', - 'type': 'error', - 'intent': 'system_error', - 'confidence': 0.0, - 'system_controlled': False, - 'timestamp': datetime.now().isoformat() - }) - # This should never be reached - world-class AI should always be available + return jsonify({ + 'text': 'No AI system available. Please check configuration.', + 'type': 'error', + 'timestamp': datetime.now().isoformat() + }) except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/static/scenario-controls.js b/static/scenario-controls.js index 3a16c88..eef3353 100644 --- a/static/scenario-controls.js +++ b/static/scenario-controls.js @@ -966,6 +966,76 @@ class ScenarioControllerUI { this.handleSystemUpdate(data); }); + // --- AGENTIC CHATBOT EVENTS --- + // These are emitted by the agentic chatbot when it executes tools + + this.socket.on('scenario_time_update', (data) => { + console.log('[Agentic] Time update:', data); + if (data.hour !== undefined) { + const time = data.hour + (data.minute || 0) / 60; + this.currentTime = time; + const timeSlider = document.getElementById('time-slider'); + if (timeSlider) timeSlider.value = time; + this.updateTimeDisplay(time); + } + }); + + this.socket.on('scenario_temp_update', (data) => { + console.log('[Agentic] Temperature update:', data); + if (data.temperature !== undefined) { + this.currentTemperature = data.temperature; + const tempSlider = document.getElementById('temperature-slider'); + if (tempSlider) tempSlider.value = data.temperature; + const tempDisplay = document.getElementById('temperature-value'); + if (tempDisplay) tempDisplay.textContent = `${Math.round(data.temperature)}Β°F`; + } + }); + + this.socket.on('simulation_speed_update', (data) => { + console.log('[Agentic] Speed update:', data); + if (data.speed !== undefined) { + const speedSlider = document.getElementById('speed-slider'); + if (speedSlider) speedSlider.value = data.speed; + const speedDisplay = document.getElementById('speed-value'); + if (speedDisplay) speedDisplay.textContent = `${data.speed}x`; + } + }); + + this.socket.on('substation_update', (data) => { + console.log('[Agentic] Substation update:', data); + // Trigger a network state refresh + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('v2g_update', (data) => { + console.log('[Agentic] V2G update:', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('simulation_state_update', (data) => { + console.log('[Agentic] Simulation state:', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('ai_map_focus', (data) => { + console.log('[Agentic] Map focus:', data); + if (data && data.coordinates && window.map) { + const coords = Array.isArray(data.coordinates) + ? data.coordinates + : [data.coordinates.lon || data.coordinates[0], data.coordinates.lat || data.coordinates[1]]; + window.map.flyTo({ + center: coords, + zoom: data.zoom || 16, + duration: 2000 + }); + } + }); + + this.socket.on('scenario_change', (data) => { + console.log('[Agentic] Scenario change:', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + // Initial fetch just to be safe setTimeout(() => this.updateStatus(), 500); } diff --git a/static/script.js b/static/script.js index 18a1843..49b516f 100644 --- a/static/script.js +++ b/static/script.js @@ -296,10 +296,20 @@ enableDebugMode: window.location.hash === '#debug' }; - // ========================================== - // MAPBOX INITIALIZATION WITH PREMIUM SETTINGS - // ========================================== - mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN_HERE'; + // Load Mapbox token from backend (served from MAPBOX_TOKEN env var) + let _mapboxToken = ''; + try { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/api/config', false); // synchronous β€” must complete before map init + xhr.send(); + if (xhr.status === 200) { + const config = JSON.parse(xhr.responseText); + _mapboxToken = config.mapbox_token || ''; + } + } catch (e) { + console.warn('Could not fetch config, using fallback token'); + } + mapboxgl.accessToken = _mapboxToken || 'YOUR_MAPBOX_ACCESS_TOKEN_HERE'; const map = new mapboxgl.Map({ container: 'map', @@ -4131,45 +4141,9 @@ function initializeEVStationLayer() { input.value = ''; - // **WORLD-CLASS LLM SCENARIO PROCESSING** - // First check if this is a scenario command - if (window.llmScenarioHandler) { - window.llmScenarioHandler.processCommand(text).then(result => { - if (result !== null) { - // Scenario command was handled - let responseHtml = `
`; - responseHtml += `
`; - responseHtml += `Ultra-Intelligent AI`; - responseHtml += `
${window.renderMarkdown(result.message)}
`; - - // Add suggestions if available - if (result.suggestions && result.suggestions.length > 0) { - responseHtml += `
πŸ’‘ Suggested test scenarios:
`; - responseHtml += `
`; - result.suggestions.forEach(suggestion => { - const scenarioKey = suggestion.key; - const scenario = window.llmScenarioHandler.testScenarios[scenarioKey]; - if (scenario) { - // Send the scenario KEY, not the full description - responseHtml += ``; - } - }); - responseHtml += `
`; - } - - responseHtml += `
`; - box.innerHTML += responseHtml; - box.scrollTop = box.scrollHeight; - return; - } - - // Not a scenario command, proceed with normal AI chat - proceedWithNormalChat(text, box); - }); - } else { - // Scenario handler not available, proceed with normal chat - proceedWithNormalChat(text, box); - } + // ALL commands are now processed by the backend agentic chatbot. + // Frontend interception (llmScenarioHandler) is bypassed. + proceedWithNormalChat(text, box); } // Separated normal chat logic From db52edf25bf0275aeb9ae4741477b79c030729d0 Mon Sep 17 00:00:00 2001 From: spicyneutrino <107169289+spicyneutrino@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:33:53 -0600 Subject: [PATCH 3/5] feat: expand agentic chatbot with 5 new tools, bug fixes, and frontend sync New Tools: - toggle_map_layer: show/hide map layers (lights, cables, EVs, substations, vehicles) - set_map_view: switch between 2D (flat) and 3D (tilted) map perspectives - fail_ev_station / restore_ev_station: individual EV station control - trigger_blackout: fail all substations except one spare in a single command Bug Fixes: - Fix _run_ev_rush_test spawn queue format (was pushing {count: 30} instead of individual items with ev_percentage/battery_min_soc/battery_max_soc) - Fix PyPSA Series index alignment in power_system.py to prevent ValueError on mismatched indices - Fix scenario event log time format (use _format_time instead of raw value) Improvements: - System prompt now includes exact valid substation names, EV station IDs, and layer names to prevent LLM name-guessing failures - Conversation history preserves tool call summaries across turns so LLM remembers past actions - 6 new _emit_ui_updates handlers for real-time frontend sync (layer_toggle, map_view_change, ev_station_update, substation_update, ev_config_update, vehicles_spawned) - 7 new Socket.IO listeners in scenario-controls.js for all new chatbot events - Exposed toggleLayer, updateEVPercentage, updateBatteryRange on window for socket event handlers --- agentic_chatbot.py | 72 ++++++- agentic_tools.py | 365 ++++++++++++++++++++++++++++++++++-- core/power_system.py | 12 ++ scenario_controller.py | 2 +- static/scenario-controls.js | 57 ++++++ static/script.js | 5 + 6 files changed, 494 insertions(+), 19 deletions(-) diff --git a/agentic_chatbot.py b/agentic_chatbot.py index 49474bf..5c89e03 100644 --- a/agentic_chatbot.py +++ b/agentic_chatbot.py @@ -152,6 +152,9 @@ def _build_system_prompt(self) -> str: vehicle_text = "Running (stats unavailable)" # --- Build the prompt --- + substation_names = ', '.join(self.integrated_system.substations.keys()) + ev_station_ids = ', '.join(self.integrated_system.ev_stations.keys()) + return f"""You are the Manhattan Power Grid AI Controller β€” an expert system managing NYC's electrical infrastructure. ═══════════════════════════════════════════════ @@ -168,10 +171,16 @@ def _build_system_prompt(self) -> str: {"⚠️ FAILED SUBSTATIONS: " + ", ".join(failed_list) if failed_list else "βœ… All substations operational"} ═══════════════════════════════════════════════ +VALID NAMES (use these exact strings in tool calls): + Substations: {substation_names} + EV Stations: {ev_station_ids} + Map Layers: lights, primary, secondary, vehicles, ev, substations + CAPABILITIES: You have full control over the Manhattan power grid through the tools provided. You can control substations, manage V2G, run simulations, set time/temperature, -focus the map, and query system status. +focus the map, toggle map layers, control individual EV stations, trigger blackouts, +and query system status. RULES: 1. Use tools for ALL actions β€” never pretend to execute commands without tools @@ -180,8 +189,8 @@ def _build_system_prompt(self) -> str: 4. If a tool fails, explain why and suggest alternatives 5. For status queries, use get_system_status or get_scenario_status tools 6. Be concise but thorough β€” include numbers and specifics -7. For ambiguous substation names, try to fuzzy-match (e.g. "times sq" β†’ "Times Square") -8. When the user asks about the system, use tools to get fresh data rather than guessing +7. When the user asks about the system, use tools to get fresh data rather than guessing +8. When showing infrastructure, use toggle_map_layer to highlight relevant layers """ # ========================================================================= @@ -296,9 +305,20 @@ async def chat(self, user_input: str, user_id: str = 'web_user') -> dict: # 4. Get final text response final_text = response.choices[0].message.content or "Action completed." - # 5. Update conversation history + # 5. Update conversation history (including tool calls for memory) self.conversation_history.append({"role": "user", "content": user_input}) - self.conversation_history.append({"role": "assistant", "content": final_text}) + # Store tool call summaries so the LLM remembers what it did + if tool_results: + tool_summary = "; ".join( + f"{t['tool']}({t['args']}) β†’ {'βœ“' if t['result'].get('success') else 'βœ—'}" + for t in tool_results + ) + self.conversation_history.append({ + "role": "assistant", + "content": f"[Tools executed: {tool_summary}]\n\n{final_text}" + }) + else: + self.conversation_history.append({"role": "assistant", "content": final_text}) # Trim history if len(self.conversation_history) > self.max_history * 2: @@ -394,6 +414,48 @@ def _emit_ui_updates(self, tool_name: str, tool_args: dict, result: dict): if k in ('success', 'scenario', 'message')} }) + elif tool_name == "configure_ev" and result.get("success"): + # Push updated EV config to frontend so sliders update + self.socketio.emit('ev_config_update', { + 'ev_percentage': tool_args.get('ev_percentage', 70), + 'battery_min_soc': tool_args.get('battery_min_soc', 20), + 'battery_max_soc': tool_args.get('battery_max_soc', 90), + }) + + elif tool_name == "spawn_vehicles" and result.get("success"): + self.socketio.emit('vehicles_spawned', { + 'count': tool_args.get('count', 0), + 'message': result.get('message', '') + }) + + elif tool_name == "toggle_map_layer" and result.get("success"): + self.socketio.emit('layer_toggle', { + 'layer': tool_args.get('layer'), + 'visible': tool_args.get('visible'), + }) + + elif tool_name == "set_map_view" and result.get("success"): + self.socketio.emit('map_view_change', { + 'mode': result.get('mode'), + 'pitch': result.get('pitch'), + 'bearing': result.get('bearing'), + }) + + elif tool_name in ("fail_ev_station", "restore_ev_station") and result.get("success"): + # Refresh network state so the frontend picks up the change + self.socketio.emit('ev_station_update', { + 'station_id': result.get('station_id'), + 'action': 'failed' if tool_name == 'fail_ev_station' else 'restored', + }) + + elif tool_name == "trigger_blackout" and result.get("success"): + # Bulk substation failure β€” refresh the entire network + self.socketio.emit('substation_update', { + 'action': 'blackout', + 'failed': result.get('failed_substations', []), + 'spare': result.get('spare_substation'), + }) + except Exception as e: print(f"[AGENTIC] Socket.IO emit error: {e}") diff --git a/agentic_tools.py b/agentic_tools.py index 0216b17..418bac4 100644 --- a/agentic_tools.py +++ b/agentic_tools.py @@ -404,6 +404,122 @@ } } }, + + # ------------------------------------------------------------------------- + # MAP LAYER CONTROL + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "toggle_map_layer", + "description": "Show or hide a map visualization layer. Useful for focusing the user's attention on specific infrastructure.", + "parameters": { + "type": "object", + "properties": { + "layer": { + "type": "string", + "enum": ["lights", "primary", "secondary", "vehicles", "ev", "substations"], + "description": "Layer to toggle: 'lights' (traffic lights), 'primary' (primary power cables), 'secondary' (secondary cables), 'vehicles' (cars/EVs), 'ev' (EV charging stations), 'substations' (power substations)" + }, + "visible": { + "type": "boolean", + "description": "True to show the layer, false to hide it" + } + }, + "required": ["layer", "visible"] + } + } + }, + + # ------------------------------------------------------------------------- + # EV STATION CONTROL + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "fail_ev_station", + "description": "Take an individual EV charging station offline. Vehicles currently charging will be released. Requires SUMO to be running.", + "parameters": { + "type": "object", + "properties": { + "station_id": { + "type": "string", + "description": "ID of the EV station to fail (e.g. 'ev_station_1')" + } + }, + "required": ["station_id"] + } + } + }, + { + "type": "function", + "function": { + "name": "restore_ev_station", + "description": "Bring a failed EV charging station back online. Its parent substation must be operational.", + "parameters": { + "type": "object", + "properties": { + "station_id": { + "type": "string", + "description": "ID of the EV station to restore (e.g. 'ev_station_1')" + } + }, + "required": ["station_id"] + } + } + }, + + # ------------------------------------------------------------------------- + # BLACKOUT SCENARIO + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "trigger_blackout", + "description": "Trigger a city-wide blackout by failing ALL substations except one spare. This is a dramatic scenario for testing grid resilience and V2G emergency response.", + "parameters": { + "type": "object", + "properties": { + "spare_substation": { + "type": "string", + "description": "Name of the one substation to keep online (default: 'Midtown East')", + "default": "Midtown East" + } + }, + "required": [] + } + } + }, + + # ------------------------------------------------------------------------- + # MAP VIEW (2D / 3D) + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "set_map_view", + "description": "Switch the map between 2D (flat, top-down) and 3D (tilted, perspective) views or set a custom camera angle.", + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["2d", "3d"], + "description": "'2d' for flat top-down view, '3d' for tilted perspective view" + }, + "pitch": { + "type": "number", + "description": "Optional custom camera pitch in degrees (0=flat, 60=tilted). Overrides mode if provided." + }, + "bearing": { + "type": "number", + "description": "Optional camera rotation in degrees (0=north, 90=east). Overrides mode if provided." + } + }, + "required": ["mode"] + } + } + }, ] @@ -457,6 +573,13 @@ def __init__(self, integrated_system, v2g_manager, sumo_manager, "get_load_forecast": self._get_load_forecast, # Map Control "focus_map": self._focus_map, + "toggle_map_layer": self._toggle_map_layer, + "set_map_view": self._set_map_view, + # EV Station Control + "fail_ev_station": self._fail_ev_station, + "restore_ev_station": self._restore_ev_station, + # Blackout + "trigger_blackout": self._trigger_blackout, # Test Scenarios "run_ev_rush_test": self._run_ev_rush_test, "run_v2g_test": self._run_v2g_test, @@ -658,18 +781,25 @@ def _start_simulation(self, vehicle_count: int = 50, ev_percentage: int = 70) -> vehicle_count = max(1, min(500, vehicle_count)) ev_pct = max(0, min(100, ev_percentage)) / 100.0 - success = self.sumo_manager.start( - vehicle_count=vehicle_count, - ev_percentage=ev_pct - ) + # Start SUMO process (the method is start_sumo, not start) + success = self.sumo_manager.start_sumo(gui=False, seed=42) if success: self.system_state['sumo_running'] = True + + # Spawn vehicles (start_sumo only launches the process, + # vehicles must be spawned separately) + spawned = self.sumo_manager.spawn_vehicles( + count=vehicle_count, + ev_percentage=ev_pct + ) + return { "success": True, "vehicle_count": vehicle_count, + "vehicles_spawned": spawned, "ev_percentage": ev_percentage, - "message": f"Simulation started with {vehicle_count} vehicles ({ev_percentage}% electric)" + "message": f"Simulation started with {spawned} vehicles ({ev_percentage}% electric)" } else: return {"success": False, "error": "SUMO failed to start"} @@ -695,18 +825,30 @@ def _spawn_vehicles(self, count: int = 50) -> dict: count = max(1, min(500, count)) + # Get current EV config for spawn parameters + ev_pct = self.current_ev_config.get('ev_percentage', 70) / 100.0 + batt_min = self.current_ev_config.get('battery_min_soc', 20) / 100.0 + batt_max = self.current_ev_config.get('battery_max_soc', 90) / 100.0 + # Use the spawn queue (async, non-blocking) + # Each item must have ev_percentage, battery_min_soc, battery_max_soc + # matching the format simulation_loop expects if self.vehicle_spawn_queue is not None: - self.vehicle_spawn_queue.append({ - 'count': count, - 'timestamp': datetime.now().isoformat() - }) + for _ in range(count): + self.vehicle_spawn_queue.append({ + 'ev_percentage': ev_pct, + 'battery_min_soc': batt_min, + 'battery_max_soc': batt_max, + }) return {"success": True, "count": count, "message": f"Queued {count} vehicles for spawning"} else: # Direct spawn try: - self.sumo_manager.spawn_vehicles(count) - return {"success": True, "count": count, "message": f"Spawned {count} vehicles"} + spawned = self.sumo_manager.spawn_vehicles( + count, ev_percentage=ev_pct, + battery_min_soc=batt_min, battery_max_soc=batt_max + ) + return {"success": True, "count": count, "spawned": spawned, "message": f"Spawned {spawned} vehicles"} except Exception as e: return {"success": False, "error": str(e)} @@ -762,12 +904,38 @@ def _set_temperature(self, temperature: float) -> dict: return {"success": False, "error": str(e)} def _run_scenario(self, scenario: str) -> dict: - """Run a predefined test scenario""" + """Run a predefined test scenario β€” also starts SUMO if not running""" if not self.scenario_controller: return {"success": False, "error": "Scenario controller not available"} try: result = self.scenario_controller.run_scenario(scenario) + + # Auto-start SUMO simulation if not already running + # so that vehicles actually appear on the map + if not self.system_state.get('sumo_running'): + # Vehicle counts matching ScenarioController.run_scenario() + scenario_vehicles = { + 'rush_hour_stress_test': (100, 70), + 'evening_peak_v2g': (80, 70), + 'winter_emergency': (60, 70), + 'summer_heatwave': (90, 70), + 'heatwave_crisis': (90, 70), + 'catastrophic_heat': (100, 70), + 'late_night_low_load': (20, 70), + } + vehicle_count, ev_pct = scenario_vehicles.get(scenario, (50, 70)) + sim_result = self._start_simulation( + vehicle_count=vehicle_count, + ev_percentage=ev_pct + ) + result['simulation_started'] = sim_result.get('success', False) + if sim_result.get('success'): + result['events'] = result.get('events', []) + result['events'].append( + f"SUMO simulation started with {vehicle_count} vehicles ({ev_pct}% EV)" + ) + return {"success": True, "scenario": scenario, **result} except Exception as e: return {"success": False, "error": str(e)} @@ -962,6 +1130,28 @@ def _focus_map(self, location: str) -> dict: } } + # ========================================================================= + # MAP VIEW (2D / 3D) + # ========================================================================= + + def _set_map_view(self, mode: str = "3d", pitch: float = None, bearing: float = None) -> dict: + """Switch between 2D and 3D map views""" + presets = { + '2d': {'pitch': 0, 'bearing': 0}, + '3d': {'pitch': 60, 'bearing': -17.6}, + } + preset = presets.get(mode, presets['3d']) + final_pitch = pitch if pitch is not None else preset['pitch'] + final_bearing = bearing if bearing is not None else preset['bearing'] + + return { + "success": True, + "mode": mode, + "pitch": final_pitch, + "bearing": final_bearing, + "message": f"Map switched to {mode.upper()} view (pitch={final_pitch}Β°, bearing={final_bearing}Β°)" + } + # ========================================================================= # TEST SCENARIOS # ========================================================================= @@ -973,7 +1163,12 @@ def _run_ev_rush_test(self) -> dict: try: count = 30 if self.vehicle_spawn_queue is not None: - self.vehicle_spawn_queue.append({'count': count, 'force_low_battery': True}) + for _ in range(count): + self.vehicle_spawn_queue.append({ + 'ev_percentage': 1.0, # 100% EVs for rush test + 'battery_min_soc': 0.05, # Very low battery + 'battery_max_soc': 0.25, # Still low + }) return {"success": True, "message": f"EV rush test started β€” spawning {count} low-battery EVs"} except Exception as e: return {"success": False, "error": str(e)} @@ -1005,3 +1200,147 @@ def _run_v2g_test(self) -> dict: "v2g_result": v2g_result, "message": f"V2G test started: {target} failed and V2G enabled. Monitor V2G status for restoration progress." } + + # ========================================================================= + # MAP LAYER CONTROL + # ========================================================================= + + def _toggle_map_layer(self, layer: str, visible: bool) -> dict: + """Toggle a map visualization layer on/off""" + valid_layers = ['lights', 'primary', 'secondary', 'vehicles', 'ev', 'substations'] + if layer not in valid_layers: + return {"success": False, "error": f"Unknown layer '{layer}'. Valid: {valid_layers}"} + + layer_labels = { + 'lights': 'Traffic Lights', + 'primary': 'Primary Power Cables', + 'secondary': 'Secondary Power Cables', + 'vehicles': 'Vehicles', + 'ev': 'EV Charging Stations', + 'substations': 'Power Substations' + } + + return { + "success": True, + "layer": layer, + "visible": visible, + "label": layer_labels[layer], + "message": f"{'Showing' if visible else 'Hiding'} {layer_labels[layer]} on map" + } + + # ========================================================================= + # EV STATION CONTROL + # ========================================================================= + + def _fail_ev_station(self, station_id: str) -> dict: + """Fail an individual EV charging station""" + if station_id not in self.integrated_system.ev_stations: + available = list(self.integrated_system.ev_stations.keys()) + return {"success": False, "error": f"Station '{station_id}' not found. Available: {available}"} + + ev_station = self.integrated_system.ev_stations[station_id] + if not ev_station.get('operational', True): + return {"success": False, "error": f"{station_id} is already offline"} + + # Handle station failure in station manager + released_vehicles = [] + if (hasattr(self.sumo_manager, 'station_manager') and + self.sumo_manager.station_manager): + released_vehicles = self.sumo_manager.station_manager.handle_station_failure(station_id) + # Clear assignment on released vehicles + if released_vehicles and hasattr(self.sumo_manager, 'vehicles'): + for veh_id in released_vehicles: + if veh_id in self.sumo_manager.vehicles: + v = self.sumo_manager.vehicles[veh_id] + if hasattr(v, 'is_charging'): + v.is_charging = False + if hasattr(v, 'assigned_ev_station'): + v.assigned_ev_station = None + + # Update integrated system + ev_station['operational'] = False + + # Update SUMO station status + if station_id in getattr(self.sumo_manager, 'ev_stations_sumo', {}): + self.sumo_manager.ev_stations_sumo[station_id]['available'] = 0 + + return { + "success": True, + "station_id": station_id, + "station_name": ev_station.get('name', station_id), + "released_vehicles": len(released_vehicles), + "message": f"{ev_station.get('name', station_id)} taken offline β€” {len(released_vehicles)} vehicles released" + } + + def _restore_ev_station(self, station_id: str) -> dict: + """Restore a failed EV station""" + if station_id not in self.integrated_system.ev_stations: + available = list(self.integrated_system.ev_stations.keys()) + return {"success": False, "error": f"Station '{station_id}' not found. Available: {available}"} + + ev_station = self.integrated_system.ev_stations[station_id] + if ev_station.get('operational', True): + return {"success": False, "error": f"{station_id} is already operational"} + + # Check parent substation + parent_sub = ev_station.get('substation') + if parent_sub and parent_sub in self.integrated_system.substations: + if not self.integrated_system.substations[parent_sub].get('operational', True): + return { + "success": False, + "error": f"Cannot restore β€” parent substation '{parent_sub}' is offline. Restore it first." + } + + ev_station['operational'] = True + + # Restore SUMO station capacity + if station_id in getattr(self.sumo_manager, 'ev_stations_sumo', {}): + self.sumo_manager.ev_stations_sumo[station_id]['available'] = ev_station.get('chargers', 4) + + if (hasattr(self.sumo_manager, 'station_manager') and + self.sumo_manager.station_manager and + station_id in self.sumo_manager.station_manager.stations): + self.sumo_manager.station_manager.stations[station_id]['operational'] = True + + return { + "success": True, + "station_id": station_id, + "station_name": ev_station.get('name', station_id), + "message": f"{ev_station.get('name', station_id)} restored to service" + } + + # ========================================================================= + # BLACKOUT SCENARIO + # ========================================================================= + + def _trigger_blackout(self, spare_substation: str = "Midtown East") -> dict: + """Trigger city-wide blackout β€” fail all substations except one spare""" + failed = [] + skipped = [] + spare_found = False + + for name in list(self.integrated_system.substations.keys()): + if name == spare_substation: + spare_found = True + skipped.append(name) + continue + sub_data = self.integrated_system.substations[name] + if not sub_data.get('operational', True): + skipped.append(name) + continue + result = self._fail_substation(name) + if result.get('success'): + failed.append(name) + + if not spare_found: + # If the specified spare doesn't exist, warn but proceed + pass + + return { + "success": True, + "failed_substations": failed, + "spare_substation": spare_substation, + "count_failed": len(failed), + "already_offline": [s for s in skipped if s != spare_substation], + "message": f"⚠️ BLACKOUT: {len(failed)} substations taken offline. Only {spare_substation} remains operational." + } diff --git a/core/power_system.py b/core/power_system.py index 8318bc1..1d006ef 100644 --- a/core/power_system.py +++ b/core/power_system.py @@ -525,6 +525,12 @@ def _analyze_power_flow_results(self) -> PowerFlowResult: # Get line flows line_flows = self.network.lines_t.p0.iloc[0] line_limits = self.network.lines.s_nom + + # Align indices to ensure safe arithmetic + common_idx = line_flows.index.intersection(line_limits.index) + line_flows = line_flows.loc[common_idx] + line_limits = line_limits.loc[common_idx] + line_loading = abs(line_flows) / line_limits # Find violations @@ -886,6 +892,12 @@ def _calculate_health_score(self) -> float: # Deduct for overloaded lines line_flows = abs(self.network.lines_t.p0.iloc[0]) line_limits = self.network.lines.s_nom + + # Align indices + common_idx = line_flows.index.intersection(line_limits.index) + line_flows = line_flows.loc[common_idx] + line_limits = line_limits.loc[common_idx] + overloads = (line_flows > line_limits).sum() score -= overloads * 10 diff --git a/scenario_controller.py b/scenario_controller.py index a9e9e5c..c8bc0e5 100644 --- a/scenario_controller.py +++ b/scenario_controller.py @@ -555,7 +555,7 @@ def _log_event(self, event_type: str, description: str): 'timestamp': datetime.now().isoformat(), 'type': event_type, 'description': description, - 'time': self.current_time, + 'time': self._format_time(self.current_time_seconds), 'temperature': self.current_temperature } self.event_log.append(event) diff --git a/static/scenario-controls.js b/static/scenario-controls.js index eef3353..b12ae8f 100644 --- a/static/scenario-controls.js +++ b/static/scenario-controls.js @@ -1036,6 +1036,63 @@ class ScenarioControllerUI { if (window.loadNetworkState) window.loadNetworkState(); }); + this.socket.on('ev_config_update', (data) => { + console.log('[Agentic] EV config update:', data); + // Update the EV sliders on the frontend + if (data.ev_percentage !== undefined && window.updateEVPercentage) { + const slider = document.getElementById('ev-percentage-slider'); + if (slider) slider.value = data.ev_percentage; + window.updateEVPercentage(data.ev_percentage); + } + if (data.battery_min_soc !== undefined) { + const minSlider = document.getElementById('battery-min-slider'); + if (minSlider) minSlider.value = data.battery_min_soc; + } + if (data.battery_max_soc !== undefined) { + const maxSlider = document.getElementById('battery-max-slider'); + if (maxSlider) maxSlider.value = data.battery_max_soc; + } + if (window.updateBatteryRange) window.updateBatteryRange(); + }); + + this.socket.on('vehicles_spawned', (data) => { + console.log('[Agentic] Vehicles spawned:', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('layer_toggle', (data) => { + console.log('[Agentic] Layer toggle:', data); + if (window.toggleLayer && data.layer) { + // toggleLayer is a toggle, so we need to check current state first + // For now, just call it β€” the chatbot's visible flag is advisory + window.toggleLayer(data.layer); + // Update the toggle checkbox UI + const toggle = document.getElementById(`layer-${data.layer}`); + if (toggle) toggle.checked = data.visible; + } + }); + + this.socket.on('ev_station_update', (data) => { + console.log('[Agentic] EV station update:', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('substation_update', (data) => { + console.log('[Agentic] Substation update (blackout):', data); + if (window.loadNetworkState) window.loadNetworkState(); + }); + + this.socket.on('map_view_change', (data) => { + console.log('[Agentic] Map view change:', data); + if (window.map) { + window.map.easeTo({ + pitch: data.pitch ?? 0, + bearing: data.bearing ?? 0, + duration: 1500 + }); + } + }); + // Initial fetch just to be safe setTimeout(() => this.updateStatus(), 500); } diff --git a/static/script.js b/static/script.js index 49b516f..9595f08 100644 --- a/static/script.js +++ b/static/script.js @@ -2708,6 +2708,11 @@ function initializeEVStationLayer() { updateBatteryRange(); } + // Expose EV config functions globally so agentic chatbot socket events can update sliders + window.updateEVPercentage = updateEVPercentage; + window.updateBatteryRange = updateBatteryRange; + window.toggleLayer = toggleLayer; + // ========================================== // CONTROL FUNCTIONS // ========================================== From 464de43b402a9a722fef9db739e97eda4ebdd519 Mon Sep 17 00:00:00 2001 From: spicyneutrino <107169289+spicyneutrino@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:55:59 -0600 Subject: [PATCH 4/5] feat: enhance frontend layout with collapsible legend and responsive controls, fix incident logging error and clean up script.js --- agentic_chatbot.py | 61 +++- agentic_tools.py | 213 ++++++++++++ core/power_system.py | 5 + index.html | 20 +- manhattan_sumo_manager.py | 19 +- scenario_controller.py | 5 +- static/scenario-controls.js | 216 ++++++++++-- static/script.js | 642 ++++++++++++++++++++++++++---------- static/styles.css | 415 ++++++++++++++++++++++- 9 files changed, 1351 insertions(+), 245 deletions(-) diff --git a/agentic_chatbot.py b/agentic_chatbot.py index 5c89e03..ba1661c 100644 --- a/agentic_chatbot.py +++ b/agentic_chatbot.py @@ -191,6 +191,9 @@ def _build_system_prompt(self) -> str: 6. Be concise but thorough β€” include numbers and specifics 7. When the user asks about the system, use tools to get fresh data rather than guessing 8. When showing infrastructure, use toggle_map_layer to highlight relevant layers +9. For DESTRUCTIVE actions (trigger_blackout, fail_substation), WARN the user first by describing what will happen and what substations will be affected, then proceed +10. For complex requests like "prepare for a heatwave" or "test resilience", use the macro tools (prepare_for_event, run_resilience_test, analyze_grid_vulnerability) β€” they chain multiple actions automatically +11. When the user's request is ambiguous (e.g. "fail the station" without specifying which), list the available options and ask for clarification before acting """ # ========================================================================= @@ -267,6 +270,15 @@ async def chat(self, user_input: str, user_id: str = 'web_user') -> dict: print(f"[AGENTIC] Iteration {iteration + 1}: Calling {tool_name}({tool_args})") + # Emit real-time progress to frontend (Feature 3: Streaming Progress) + if self.socketio: + self.socketio.emit('chatbot_tool_progress', { + 'tool': tool_name, + 'args': tool_args, + 'iteration': iteration + 1, + 'status': 'calling' + }, namespace='/') + # Execute the tool result = self.tool_executor.execute(tool_name, tool_args) @@ -367,52 +379,52 @@ def _emit_ui_updates(self, tool_name: str, tool_args: dict, result: dict): self.socketio.emit('scenario_time_update', { 'hour': result.get('hour', tool_args.get('hour')), 'minute': result.get('minute', 0) - }) + }, namespace='/') elif tool_name == "set_temperature" and result.get("success"): self.socketio.emit('scenario_temp_update', { 'temperature': result.get('temperature', tool_args.get('temperature')) - }) + }, namespace='/') elif tool_name == "set_simulation_speed" and result.get("success"): self.socketio.emit('simulation_speed_update', { 'speed': result.get('speed', tool_args.get('speed')) - }) + }, namespace='/') elif tool_name in ("fail_substation", "restore_substation") and result.get("success"): self.socketio.emit('substation_update', { 'substation': result.get('substation'), 'action': result.get('action'), 'operational': tool_name == "restore_substation" - }) + }, namespace='/') elif tool_name == "restore_all_substations" and result.get("success"): self.socketio.emit('substation_update', { 'action': 'restore_all', 'restored': result.get('restored', []) - }) + }, namespace='/') elif tool_name in ("enable_v2g", "disable_v2g") and result.get("success"): self.socketio.emit('v2g_update', { 'substation': result.get('substation'), 'action': result.get('action'), - }) + }, namespace='/') elif tool_name in ("start_simulation", "stop_simulation") and result.get("success"): self.socketio.emit('simulation_state_update', { 'running': tool_name == "start_simulation", - }) + }, namespace='/') elif tool_name == "focus_map" and result.get("success"): map_action = result.get('map_action', {}) - self.socketio.emit('ai_map_focus', map_action) + self.socketio.emit('ai_map_focus', map_action, namespace='/') elif tool_name == "run_scenario" and result.get("success"): self.socketio.emit('scenario_change', { 'scenario': tool_args.get('scenario'), 'result': {k: v for k, v in result.items() if k in ('success', 'scenario', 'message')} - }) + }, namespace='/') elif tool_name == "configure_ev" and result.get("success"): # Push updated EV config to frontend so sliders update @@ -420,33 +432,33 @@ def _emit_ui_updates(self, tool_name: str, tool_args: dict, result: dict): 'ev_percentage': tool_args.get('ev_percentage', 70), 'battery_min_soc': tool_args.get('battery_min_soc', 20), 'battery_max_soc': tool_args.get('battery_max_soc', 90), - }) + }, namespace='/') elif tool_name == "spawn_vehicles" and result.get("success"): self.socketio.emit('vehicles_spawned', { 'count': tool_args.get('count', 0), 'message': result.get('message', '') - }) + }, namespace='/') elif tool_name == "toggle_map_layer" and result.get("success"): self.socketio.emit('layer_toggle', { 'layer': tool_args.get('layer'), 'visible': tool_args.get('visible'), - }) + }, namespace='/') elif tool_name == "set_map_view" and result.get("success"): self.socketio.emit('map_view_change', { 'mode': result.get('mode'), 'pitch': result.get('pitch'), 'bearing': result.get('bearing'), - }) + }, namespace='/') elif tool_name in ("fail_ev_station", "restore_ev_station") and result.get("success"): # Refresh network state so the frontend picks up the change self.socketio.emit('ev_station_update', { 'station_id': result.get('station_id'), 'action': 'failed' if tool_name == 'fail_ev_station' else 'restored', - }) + }, namespace='/') elif tool_name == "trigger_blackout" and result.get("success"): # Bulk substation failure β€” refresh the entire network @@ -454,7 +466,26 @@ def _emit_ui_updates(self, tool_name: str, tool_args: dict, result: dict): 'action': 'blackout', 'failed': result.get('failed_substations', []), 'spare': result.get('spare_substation'), - }) + }, namespace='/') + + # --- Macro tools --- + elif tool_name == "prepare_for_event" and result.get("success"): + # Push time and temp updates since this tool changes both + self.socketio.emit('scenario_time_update', { + 'hour': result.get('hour', 12), + 'minute': result.get('minute', 0) + }, namespace='/') + self.socketio.emit('scenario_temp_update', { + 'temperature': result.get('temperature', 72) + }, namespace='/') + + elif tool_name == "run_resilience_test" and result.get("success"): + # Notify frontend of test completion + self.socketio.emit('resilience_test_complete', { + 'substation': result.get('substation'), + 'grade': result.get('resilience_grade'), + 'recovery_pct': result.get('recovery_percentage'), + }, namespace='/') except Exception as e: print(f"[AGENTIC] Socket.IO emit error: {e}") diff --git a/agentic_tools.py b/agentic_tools.py index 418bac4..decbc76 100644 --- a/agentic_tools.py +++ b/agentic_tools.py @@ -520,6 +520,57 @@ } } }, + + # ------------------------------------------------------------------------- + # MACRO / MULTI-STEP TOOLS + # ------------------------------------------------------------------------- + { + "type": "function", + "function": { + "name": "run_resilience_test", + "description": "Run a comprehensive grid resilience test: fail a substation, enable V2G recovery, monitor recovery metrics, and restore. Returns a full resilience report. This is a multi-step operation that chains several actions.", + "parameters": { + "type": "object", + "properties": { + "substation": { + "type": "string", + "description": "The substation to test resilience on (e.g. 'Times Square')" + } + }, + "required": ["substation"] + } + } + }, + { + "type": "function", + "function": { + "name": "analyze_grid_vulnerability", + "description": "Analyze the entire grid to identify the most vulnerable substations. Checks load ratios, identifies potential cascade failures, and recommends V2G pre-positioning for resilience. Returns a ranked vulnerability report.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "prepare_for_event", + "description": "Prepare the grid and simulation for a specific event type (heatwave, morning_rush, evening_peak, storm, normal). Automatically sets time, temperature, spawns appropriate vehicles, and adjusts EV config. This demonstrates intelligent orchestration.", + "parameters": { + "type": "object", + "properties": { + "event": { + "type": "string", + "enum": ["heatwave", "morning_rush", "evening_peak", "storm", "normal"], + "description": "The event type to prepare for" + } + }, + "required": ["event"] + } + } + }, ] @@ -583,6 +634,10 @@ def __init__(self, integrated_system, v2g_manager, sumo_manager, # Test Scenarios "run_ev_rush_test": self._run_ev_rush_test, "run_v2g_test": self._run_v2g_test, + # Macro / Multi-Step Tools + "run_resilience_test": self._run_resilience_test, + "analyze_grid_vulnerability": self._analyze_grid_vulnerability, + "prepare_for_event": self._prepare_for_event, } def execute(self, tool_name: str, arguments: dict) -> dict: @@ -1344,3 +1399,161 @@ def _trigger_blackout(self, spare_substation: str = "Midtown East") -> dict: "already_offline": [s for s in skipped if s != spare_substation], "message": f"⚠️ BLACKOUT: {len(failed)} substations taken offline. Only {spare_substation} remains operational." } + + # ========================================================================= + # MACRO / MULTI-STEP TOOLS + # ========================================================================= + + def _run_resilience_test(self, substation: str) -> dict: + """Multi-step resilience test: fail β†’ enable V2G β†’ measure β†’ restore.""" + steps = [] + + # Step 1: Record baseline + status_before = self._get_system_status() + baseline_load = sum( + s.get('load_mw', 0) + for s in self.integrated_system.substations.values() + if s.get('operational', True) + ) + steps.append({"step": "baseline", "total_load_mw": round(baseline_load, 1)}) + + # Step 2: Fail the substation + fail_result = self._fail_substation(substation) + if not fail_result.get('success'): + return {"success": False, "error": f"Could not fail {substation}: {fail_result.get('error', 'unknown')}", "steps": steps} + steps.append({"step": "fail_substation", "substation": substation, "result": "offline"}) + + # Step 3: Measure impact + post_fail_load = sum( + s.get('load_mw', 0) + for s in self.integrated_system.substations.values() + if s.get('operational', True) + ) + load_lost = baseline_load - post_fail_load + steps.append({"step": "measure_impact", "load_lost_mw": round(load_lost, 1), "remaining_load_mw": round(post_fail_load, 1)}) + + # Step 4: Enable V2G on the failed substation + v2g_result = self._enable_v2g(substation) + v2g_recovery_kw = 0 + if v2g_result.get('success'): + v2g_status = self._get_v2g_status() + v2g_recovery_kw = v2g_status.get('total_v2g_capacity_kw', 0) + steps.append({"step": "enable_v2g", "success": v2g_result.get('success', False), "recovery_kw": round(v2g_recovery_kw, 1)}) + + # Step 5: Calculate resilience score + recovery_pct = min(100, (v2g_recovery_kw / max(load_lost * 1000, 1)) * 100) + resilience_grade = 'A' if recovery_pct > 75 else ('B' if recovery_pct > 50 else ('C' if recovery_pct > 25 else 'D')) + steps.append({"step": "resilience_score", "recovery_pct": round(recovery_pct, 1), "grade": resilience_grade}) + + # Step 6: Restore + restore_result = self._restore_substation(substation) + steps.append({"step": "restore", "success": restore_result.get('success', False)}) + + return { + "success": True, + "substation": substation, + "resilience_grade": resilience_grade, + "recovery_percentage": round(recovery_pct, 1), + "load_lost_mw": round(load_lost, 1), + "v2g_recovery_kw": round(v2g_recovery_kw, 1), + "steps": steps, + "message": f"Resilience test complete for {substation}: Grade {resilience_grade} ({recovery_pct:.0f}% V2G recovery of {load_lost:.1f} MW lost)" + } + + def _analyze_grid_vulnerability(self) -> dict: + """Analyze all substations and rank by vulnerability.""" + vulnerabilities = [] + + for name, sub in self.integrated_system.substations.items(): + if not sub.get('operational', True): + vulnerabilities.append({ + "substation": name, + "status": "OFFLINE", + "load_ratio": 0, + "risk": "CRITICAL", + "recommendation": "Restore immediately or enable V2G" + }) + continue + + load = sub.get('load_mw', 0) + capacity = sub.get('capacity_mva', 1) # avoid div/0 + load_ratio = load / capacity + + if load_ratio > 0.85: + risk = "HIGH" + rec = "Pre-position V2G resources; consider load shedding" + elif load_ratio > 0.65: + risk = "MEDIUM" + rec = "Monitor closely; V2G standby recommended" + else: + risk = "LOW" + rec = "Operating within safe margins" + + vulnerabilities.append({ + "substation": name, + "status": "ONLINE", + "load_mw": round(load, 1), + "capacity_mva": capacity, + "load_ratio": round(load_ratio, 3), + "risk": risk, + "recommendation": rec + }) + + # Sort by load ratio descending + vulnerabilities.sort(key=lambda x: x.get('load_ratio', 0), reverse=True) + + high_risk = [v for v in vulnerabilities if v['risk'] in ('HIGH', 'CRITICAL')] + overall_risk = 'HIGH' if len(high_risk) >= 2 else ('MEDIUM' if high_risk else 'LOW') + + return { + "success": True, + "overall_risk": overall_risk, + "substations": vulnerabilities, + "high_risk_count": len(high_risk), + "total_substations": len(vulnerabilities), + "message": f"Grid vulnerability analysis: {overall_risk} overall risk. {len(high_risk)} substations at elevated risk." + } + + def _prepare_for_event(self, event: str) -> dict: + """Orchestrate time, temperature, vehicles, and EV config for an event.""" + EVENT_CONFIGS = { + "heatwave": {"hour": 14, "minute": 0, "temp": 102, "vehicles": 80, "ev_pct": 80, "label": "Summer Heatwave (2pm, 102Β°F)"}, + "morning_rush": {"hour": 8, "minute": 30, "temp": 72, "vehicles": 100, "ev_pct": 70, "label": "Morning Rush Hour (8:30am)"}, + "evening_peak": {"hour": 18, "minute": 0, "temp": 78, "vehicles": 90, "ev_pct": 75, "label": "Evening Peak (6pm)"}, + "storm": {"hour": 22, "minute": 0, "temp": 45, "vehicles": 30, "ev_pct": 60, "label": "Winter Storm (10pm, 45Β°F)"}, + "normal": {"hour": 12, "minute": 0, "temp": 72, "vehicles": 50, "ev_pct": 70, "label": "Normal Operations (noon, 72Β°F)"}, + } + + config = EVENT_CONFIGS.get(event) + if not config: + return {"success": False, "error": f"Unknown event: {event}. Valid: {list(EVENT_CONFIGS.keys())}"} + + actions = [] + + # Set time + time_result = self._set_time(hour=config["hour"], minute=config["minute"]) + actions.append({"action": "set_time", "success": time_result.get('success', False)}) + + # Set temperature + temp_result = self._set_temperature(temperature=config["temp"]) + actions.append({"action": "set_temperature", "success": temp_result.get('success', False), "value": config["temp"]}) + + # Configure EV percentage + ev_result = self._configure_ev(ev_percentage=config["ev_pct"]) + actions.append({"action": "configure_ev", "success": ev_result.get('success', False), "ev_pct": config["ev_pct"]}) + + # Spawn vehicles if simulation is running + if self.system_state.get('sumo_running'): + spawn_result = self._spawn_vehicles(count=config["vehicles"]) + actions.append({"action": "spawn_vehicles", "success": spawn_result.get('success', False), "count": config["vehicles"]}) + + return { + "success": True, + "event": event, + "label": config["label"], + "actions_taken": actions, + "hour": config["hour"], + "minute": config["minute"], + "temperature": config["temp"], + "message": f"Environment prepared for {config['label']}. {len(actions)} systems adjusted." + } diff --git a/core/power_system.py b/core/power_system.py index 1d006ef..f873d71 100644 --- a/core/power_system.py +++ b/core/power_system.py @@ -907,6 +907,11 @@ def _log_incident(self, impact: Dict[str, Any]): """Log incident to database""" try: + # Guard: Check if db_manager and get_session are available + if not hasattr(db_manager, 'get_session') or not callable(getattr(db_manager, 'get_session', None)): + # Database not configured - skip logging (this is optional for simulation) + return + with db_manager.get_session() as session: from config.database import Incident diff --git a/index.html b/index.html index 9f9951a..a3759f6 100644 --- a/index.html +++ b/index.html @@ -466,6 +466,11 @@

🌑️ 72°F

+
+
+ SYNC + Idle +
@@ -478,8 +483,11 @@

-
System Components
+ +

`) .addTo(map); @@ -2437,7 +2708,7 @@ function initializeEVStationLayer() { type: 'line', source: 'primary-cables', layout: { - 'visibility': 'none' // Hidden by default + 'visibility': layers.primary ? 'visible' : 'none' }, paint: { 'line-color': ['case', ['get', 'operational'], '#00ff88', '#ff3366'], @@ -2451,7 +2722,7 @@ function initializeEVStationLayer() { type: 'line', source: 'primary-cables', layout: { - 'visibility': 'none' // Hidden by default + 'visibility': layers.primary ? 'visible' : 'none' }, paint: { 'line-color': ['case', ['get', 'operational'], '#00ffcc', '#ff3b3b'], @@ -2481,7 +2752,7 @@ function initializeEVStationLayer() { type: 'line', source: 'secondary-cables', layout: { - 'visibility': 'none' // Hidden by default + 'visibility': layers.secondary ? 'visible' : 'none' }, paint: { 'line-color': '#ffcc66', @@ -2495,7 +2766,7 @@ function initializeEVStationLayer() { type: 'line', source: 'secondary-cables', layout: { - 'visibility': 'none' // Hidden by default + 'visibility': layers.secondary ? 'visible' : 'none' }, paint: { 'line-color': '#ffbb44', @@ -2984,6 +3255,7 @@ function initializeEVStationLayer() { // Handle V2G data from system_update event if (state.v2g) { updateV2GFromWebSocket(state.v2g); + updateV2GDashboard(state.v2g); } // Handle AI focus from system_update event @@ -3126,9 +3398,17 @@ function initializeEVStationLayer() { if (!focusData || !map) return; // Apply map focus (fly to location, highlight, etc.) - if (focusData.coordinates) { + // Apply map focus (fly to location, highlight, etc.) + let center = null; + if (Array.isArray(focusData.coordinates) && focusData.coordinates.length === 2) { + center = focusData.coordinates; + } else if (focusData.coordinates && focusData.coordinates.lat && focusData.coordinates.lon) { + center = [focusData.coordinates.lon, focusData.coordinates.lat]; + } + + if (center) { map.flyTo({ - center: [focusData.coordinates.lon, focusData.coordinates.lat], + center: center, zoom: focusData.zoom || 14, duration: 2000 }); @@ -3160,6 +3440,56 @@ function initializeEVStationLayer() { // Removed periodic setInterval polling - now using WebSockets πŸš€ + function controlLayers(layerList, message) { + if (!layerList || !Array.isArray(layerList)) { + console.error('Invalid layer list for controlLayers'); + return; + } + + console.log('Controlling layers:', layerList); + + // Define expected state (true = visible) based on typical usage + // Or simplified: just ensure they are visible? + // Usage in executeMapAction suggests it might be a list of layers to SHOW. + + layerList.forEach(layer => { + // Check current state. If we want to SHOW it and it's hidden, toggle it. + // But toggleLayer just flips it. + // We need 'setLayerVisibility' really, but toggleLayer is what we have. + // Let's assume controlLayers implies SHOWING them? + // Or maybe checking the current state? + + // Actually, let's implement a smarter setLayer method if possible, + // or just use toggleLayer if we lack direct set capability. + // script.js uses `layers[layer] = !layers[layer]`. + + // To be safe, let's look at `layers` object state. + if (!layers[layer]) { + toggleLayer(layer); + } + }); + + if (message) { + showNotification('Layer Update', message, 'info'); + } + } + + function showPowerGrid() { + const gridLayers = ['primary', 'secondary', 'substations']; + gridLayers.forEach(layer => { + if (!layers[layer]) toggleLayer(layer); + }); + showNotification('⚑ Power Grid', 'Power grid layers enabled', 'success'); + } + + function hidePowerGrid() { + const gridLayers = ['primary', 'secondary']; + gridLayers.forEach(layer => { + if (layers[layer]) toggleLayer(layer); + }); + showNotification('⚑ Power Grid', 'Power grid layers hidden', 'info'); + } + function toggleLayer(layer) { layers[layer] = !layers[layer]; @@ -3184,87 +3514,51 @@ function initializeEVStationLayer() { } } - // Track simulation time with local seconds counter - // Track simulation time with local seconds counter - const now = new Date(); - let displayHours = now.getHours(); - let displayMinutes = now.getMinutes(); - let displaySeconds = now.getSeconds(); - let timeInitialized = false; - - function updateTime() { + // ========================================================================= + // SIMULATION CLOCK β€” Single source of truth + // The backend (ScenarioController) owns the time and broadcasts it every + // second via the 'system_update' WebSocket event. The frontend ONLY + // renders whatever the backend says β€” no independent counter. + // Initialize from system clock to avoid a blank flash before the first + // WebSocket update arrives (~1 s). + // ========================================================================= + const _initNow = new Date(); + let displayHours = _initNow.getHours(); + let displayMinutes = _initNow.getMinutes(); + let displaySeconds = _initNow.getSeconds(); + + function renderTime() { const timeEl = document.getElementById('time'); if (!timeEl) return; - - // NEW: Allow external updates (e.g., from scenario-controls.js) to override checks - if (!timeInitialized) { - fetch('/api/scenario/status') - - - // Increment seconds every call (called every 1 second) - displaySeconds++; + const ampm = displayHours >= 12 ? 'PM' : 'AM'; + const hours12 = displayHours % 12 || 12; + timeEl.textContent = `${String(hours12).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}:${String(displaySeconds).padStart(2, '0')} ${ampm}`; } - // EXPOSED: Function for scenario-controls.js to force update time + // Called by scenario-controls.js handleSystemUpdate (every ~1s via WebSocket) window.updateLocalTime = function(hours, minutes, seconds = 0) { displayHours = parseInt(hours); displayMinutes = parseInt(minutes); displaySeconds = parseInt(seconds); - timeInitialized = true; // Ensure logic runs - - // Immediate update to UI to prevent flicker - const timeEl = document.getElementById('time'); - if (timeEl) { - const ampm = displayHours >= 12 ? 'PM' : 'AM'; - const hours12 = displayHours % 12 || 12; - const secsStr = String(displaySeconds).padStart(2, '0'); - timeEl.textContent = `${String(hours12).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}:${secsStr} ${ampm}`; - } + renderTime(); }; - // Increment seconds every call (called every 1 second) - displaySeconds++; - if (displaySeconds >= 60) { - displaySeconds = 0; - displayMinutes++; - if (displayMinutes >= 60) { - displayMinutes = 0; - displayHours++; - if (displayHours >= 24) { - displayHours = 0; - } - } - } - - // Convert to 12-hour format with AM/PM - const ampm = displayHours >= 12 ? 'PM' : 'AM'; - const hours12 = displayHours % 12 || 12; - - timeEl.textContent = `${String(hours12).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}:${String(displaySeconds).padStart(2, '0')} ${ampm}`; - } - - // Expose updateTime globally for scenario handlers - window.updateTime = updateTime; - - // Global debounce: any time setter (chatbot, slider) can set this to suppress auto-advance - window._lastManualTimeUpdate = 0; - - // Allow external code to sync the local clock (e.g. scenario slider, chatbot) + // Called by scenario-controls.js setTime / slider (user-initiated change) window.syncDisplayTime = function(hour, minute) { displayHours = Math.floor(hour); displayMinutes = minute !== undefined ? minute : Math.round((hour % 1) * 60); displaySeconds = 0; - timeInitialized = true; window._lastManualTimeUpdate = Date.now(); - // Immediately update the footer - const timeEl = document.getElementById('time'); - if (timeEl) { - const ampm = displayHours >= 12 ? 'PM' : 'AM'; - const hours12 = displayHours % 12 || 12; - timeEl.textContent = `${String(hours12).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}:00 ${ampm}`; - } + renderTime(); }; + // Kept for backward-compat β€” just re-renders current values (no counter) + function updateTime() { renderTime(); } + window.updateTime = updateTime; + + // Global debounce: any time setter (chatbot, slider) can set this to suppress auto-advance + window._lastManualTimeUpdate = 0; + // Sync local clock when chatbot changes time via socket if (window.socket) { window.socket.on('scenario_time_update', (data) => { @@ -3272,14 +3566,8 @@ function initializeEVStationLayer() { displayHours = Math.floor(data.hour); displayMinutes = data.minute || Math.round((data.hour % 1) * 60); displaySeconds = 0; - timeInitialized = true; - // Immediately update the footer - const timeEl = document.getElementById('time'); - if (timeEl) { - const ampm = displayHours >= 12 ? 'PM' : 'AM'; - const hours12 = displayHours % 12 || 12; - timeEl.textContent = `${String(hours12).padStart(2, '0')}:${String(displayMinutes).padStart(2, '0')}:${String(displaySeconds).padStart(2, '0')} ${ampm}`; - } + window._lastManualTimeUpdate = Date.now(); + renderTime(); } }); window.socket.on('scenario_temp_update', (data) => { @@ -3315,29 +3603,46 @@ function initializeEVStationLayer() { warning: 'linear-gradient(135deg, #ffaa00, #ff8800)' }; + let container = document.getElementById('notification-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'notification-container'; + container.style.cssText = ` + position: fixed; + top: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 10000; + pointer-events: none; + `; + document.body.appendChild(container); + } + const notification = document.createElement('div'); notification.style.cssText = ` - position: fixed; - top: 24px; - right: 24px; background: ${colors[type]}; color: rgba(0, 0, 0, 0.9); padding: 16px 20px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - z-index: 10000; max-width: 320px; animation: slideInRight 0.3s ease; font-weight: 500; + pointer-events: auto; + position: relative; `; notification.innerHTML = `${title}
${message}`; - document.body.appendChild(notification); + container.appendChild(notification); setTimeout(() => { notification.style.animation = 'slideOutRight 0.3s ease'; setTimeout(() => notification.remove(), 300); - }, 3000); + }, 4000); } + + // ========================================== // ML DASHBOARD FUNCTIONS // ========================================== @@ -3519,9 +3824,59 @@ function initializeEVStationLayer() { } } + // ========================================== + // SOCKET.IO EVENT LISTENERS + // ========================================== + + // Existing listeners... + + if (window.socket) { + window.socket.on('v2g_update', (data) => { + console.log('πŸ”‹ V2G Update:', data); + if (data.action === 'v2g_enabled') { + showNotification('πŸ”‹ V2G Activated', `Vehicle-to-Grid enabled for ${data.substation}`, 'success'); + } else if (data.action === 'v2g_disabled') { + showNotification('πŸ”Œ V2G Disabled', `Vehicle-to-Grid disabled for ${data.substation}`, 'info'); + } + if (typeof refreshV2GDashboard === 'function') refreshV2GDashboard(); + }); + } + // ========================================== // CHATBOT FUNCTIONS // ========================================== + + // NEW: Click-to-Query Logic + window.askAboutComponent = function(name, type) { + const chatWin = document.getElementById('chatbot-window'); + const launcher = document.getElementById('chatbot-launcher'); + const input = document.getElementById('chat-input'); + + // Ensure chat window is open + if (chatWin && (chatWin.style.display === 'none' || !chatWin.style.display)) { + if (typeof toggleChatbot === 'function') { + toggleChatbot(); + } else { + chatWin.style.display = 'flex'; + if (launcher) launcher.style.display = 'none'; + } + } else if (chatWin && chatWin.style.display !== 'flex') { + // Fallback if style is set to something weird or empty + chatWin.style.display = 'flex'; + if (launcher) launcher.style.display = 'none'; + } + + // Set input value + if (input) { + input.value = `Status of ${name}`; + input.focus(); + + // Optional: Auto-scroll to bottom of chat + const chatBody = document.getElementById('chat-body'); + if (chatBody) chatBody.scrollTop = chatBody.scrollHeight; + } + }; + function toggleChatbot() { const launcher = document.getElementById('chatbot-launcher'); const win = document.getElementById('chatbot-window'); @@ -3593,14 +3948,21 @@ function initializeEVStationLayer() { async function executeMapAction(mapAction) { console.log('executeMapAction called with:', mapAction); - console.log('window.map exists:', !!window.map); - console.log('window.mapLoaded:', window.mapLoaded); - + if (!mapAction) { console.error('No map action provided'); return; } + // Normalize coordinates (handle coords vs coordinates) + if (mapAction.coords && !mapAction.coordinates) { + mapAction.coordinates = mapAction.coords; + } + // Handle single dict coord vs array + if (mapAction.coordinates && !Array.isArray(mapAction.coordinates) && mapAction.coordinates.lat) { + mapAction.coordinates = [mapAction.coordinates.lon, mapAction.coordinates.lat]; + } + if (!window.map) { console.error('Map not available'); showNotification('❌ Map Error', 'Map not loaded yet', 'error'); @@ -3623,13 +3985,15 @@ function initializeEVStationLayer() { switch (mapAction.type) { case 'zoom_to_location': case 'focus_and_highlight': - console.log('Processing focus_and_highlight action:', mapAction); + case 'highlight_location': // Added support for new tool + console.log('Processing map highlight action:', mapAction); if (mapAction.coordinates && mapAction.coordinates.length === 2) { console.log('Flying to coordinates:', mapAction.coordinates); // CRITICAL: Reload network state to update map with restored substation - await loadNetworkState(); + // Only if needed (restoration implied?) + if (mapAction.type === 'highlight_restore') await loadNetworkState(); // Clear previous highlights clearAllHighlights(); @@ -3639,13 +4003,11 @@ function initializeEVStationLayer() { window.map.flyTo({ center: mapAction.coordinates, zoom: mapAction.zoom || 16, + pitch: mapAction.pitch || 45, duration: 1500, essential: true, - easing(t) { - return t * (2 - t); // easeOutQuad - } + easing(t) { return t * (2 - t); } }); - console.log('Map flyTo executed successfully'); } catch (flyError) { console.error('Map flyTo error:', flyError); showNotification('❌ Map Error', 'Could not focus on location', 'error'); @@ -3654,7 +4016,6 @@ function initializeEVStationLayer() { // Add advanced highlight with delay setTimeout(() => { - console.log('Creating advanced highlight...'); createAdvancedHighlight({ coordinates: mapAction.coordinates, name: mapAction.name || mapAction.location || 'Location', @@ -3666,11 +4027,11 @@ function initializeEVStationLayer() { }, 800); // Show success notification - showNotification('πŸ—ΊοΈ Location Found', `Showing ${mapAction.name || mapAction.location || 'location'} on map`, 'success'); - console.log('Map action executed successfully'); + const locName = mapAction.name || mapAction.location || 'location'; + showNotification('πŸ—ΊοΈ Location Found', `Showing ${locName} on map`, 'success'); } else { console.error('Invalid coordinates:', mapAction.coordinates); - showNotification('❌ Map Error', 'Invalid location coordinates', 'error'); + showNotification('❌ Map Error', `Invalid coordinates for ${mapAction.location || 'location'}`, 'error'); } break; @@ -3679,9 +4040,42 @@ function initializeEVStationLayer() { case 'highlight_restore': if (mapAction.substation_id || mapAction.location || mapAction.name) { const locationName = mapAction.location || mapAction.name || mapAction.substation_id; - console.log('Highlighting substation:', locationName, mapAction.coordinates || mapAction.coords); - highlightSubstationAdvanced(locationName, mapAction.coordinates || mapAction.coords); - showNotification('🏭 Substation Highlighted', `Showing ${locationName} on map`, 'info'); + console.log('Highlighting substation:', locationName, mapAction.coordinates); + + // Fly to location if coordinates are provided + // Fly to location if coordinates are provided + let center = null; + if (Array.isArray(mapAction.coordinates) && mapAction.coordinates.length === 2) { + center = mapAction.coordinates; + } else if (mapAction.coordinates && mapAction.coordinates.lat && mapAction.coordinates.lon) { + center = [mapAction.coordinates.lon, mapAction.coordinates.lat]; + } + + if (center) { + window.map.flyTo({ + center: center, + zoom: mapAction.zoom || 15, + pitch: mapAction.pitch || 45, + duration: 1500, + essential: true + }); + } + + // Use advanced highlighter + if (center) { + highlightSubstationAdvanced(locationName, center); + } else { + highlightSubstationAdvanced(locationName, mapAction.coordinates); + } + + // Specific notification per type + if (mapAction.type === 'highlight_failure') { + showNotification('⚠️ Substation Failure', `${locationName} is OFFLINE`, 'error'); + } else if (mapAction.type === 'highlight_restore') { + showNotification('βœ… Substation Restored', `${locationName} is back ONLINE`, 'success'); + } else { + showNotification('🏭 Substation Highlighted', `Showing ${locationName}`, 'info'); + } } break; @@ -3724,7 +4118,6 @@ function initializeEVStationLayer() { break; case 'show_substation_network': - console.log('πŸ”Œ Showing individual substation network:', mapAction); if (mapAction.substation_name) { showSubstationNetwork(mapAction.substation_name, { showCables: mapAction.show_cables || true, @@ -3734,61 +4127,51 @@ function initializeEVStationLayer() { break; case 'show_ev_charging': - console.log('⚑ Showing EV charging station for substation:', mapAction); if (mapAction.substation && mapAction.coordinates) { - // Enable EV layer if not already enabled const evToggle = document.getElementById('layer-ev'); if (evToggle && !evToggle.checked) { evToggle.checked = true; toggleLayer('ev'); } - - // Fly to the substation location window.map.flyTo({ center: mapAction.coordinates, zoom: mapAction.zoom || 16, duration: 1500, - essential: true, - easing(t) { - return t * (2 - t); // easeOutQuad - } + essential: true }); - - // Highlight the EV station and substation setTimeout(() => { highlightEVStations(mapAction.substation); createAdvancedHighlight({ coordinates: mapAction.coordinates, - name: `${mapAction.substation} - EV Charging Station`, + name: `${mapAction.substation} - EV Charging`, type: 'ev_station', duration: 15000, - pulseColor: '#00ffff', - showConnections: true + pulseColor: '#00ffff' }); }, 800); - showNotification('⚑ EV Charging Station', `Showing EV station for ${mapAction.substation}`, 'success'); } break; case 'control_layers': - console.log('πŸŽ›οΈ Controlling layers:', mapAction); if (mapAction.layers && Array.isArray(mapAction.layers)) { controlLayers(mapAction.layers, mapAction.message || ''); } break; case 'focus_location': - if (mapAction.coords) { + // Already handled by normalization logic + general flyTo above? + // But if it slips through: + if (mapAction.coordinates) { window.map.flyTo({ - center: mapAction.coords, + center: mapAction.coordinates, zoom: mapAction.zoom || 16, duration: 2000, essential: true }); - setTimeout(() => { + setTimeout(() => { createAdvancedHighlight({ - coordinates: mapAction.coords, + coordinates: mapAction.coordinates, name: mapAction.name || 'Location', type: 'destination', duration: 20000, @@ -3801,69 +4184,61 @@ function initializeEVStationLayer() { case 'show_all_vehicles': highlightAllVehicles(mapAction); - showNotification('πŸš— Vehicle Display', 'All vehicles highlighted with tracking', 'info'); + showNotification('πŸš— Vehicle Display', 'All vehicles highlighted', 'info'); break; case 'visualize_grid': visualizePowerGrid(mapAction); - showNotification('⚑ Grid Visualization', 'Power grid network displayed', 'info'); + showNotification('⚑ Grid Visualization', 'Power grid displayed', 'info'); break; case 'show_heatmap': showHeatmapOverlay(mapAction); - showNotification('🌑️ Heatmap Active', (mapAction.data_type || 'Data') + ' heatmap overlay enabled', 'info'); + showNotification('🌑️ Heatmap Active', 'Heatmap overlay enabled', 'info'); break; case 'zoom_change': - // Handle zoom in/out commands const currentZoom = window.map.getZoom(); const newZoom = currentZoom + (mapAction.delta || 0); window.map.easeTo({ zoom: newZoom, duration: 800, - easing(t) { - return t * (2 - t); // easeOutQuad - } + easing(t) { return t * (2 - t); } }); - const direction = mapAction.delta > 0 ? 'in' : 'out'; - showNotification('πŸ” Zoom', `Zooming ${direction} to level ${newZoom.toFixed(1)}`, 'info'); break; case 'set_zoom': - // Set specific zoom level window.map.easeTo({ zoom: mapAction.level || 12, duration: 1000, - easing(t) { - return t * (2 - t); - } + easing(t) { return t * (2 - t); } }); - showNotification('πŸ” Zoom', `Zoom level set to ${mapAction.level || 12}`, 'info'); break; case 'set_camera': - // Set camera pitch and bearing window.map.easeTo({ pitch: mapAction.pitch !== undefined ? mapAction.pitch : window.map.getPitch(), bearing: mapAction.bearing !== undefined ? mapAction.bearing : window.map.getBearing(), zoom: mapAction.zoom !== undefined ? mapAction.zoom : window.map.getZoom(), - duration: 1500, - easing(t) { - return t * (2 - t); - } + duration: 1500 }); - showNotification('πŸ“· Camera', 'Camera view adjusted', 'info'); break; - } - // Enhanced notification system - if (mapAction.message && !mapAction.type.includes('highlight')) { - showNotification(`πŸ—ΊοΈ Map Action`, mapAction.message, 'info'); + case 'set_map_view': + const pitch2d = mapAction.mode === '2d' ? 0 : (mapAction.pitch !== undefined ? mapAction.pitch : 60); + const bearing2d = mapAction.bearing !== undefined ? mapAction.bearing : window.map.getBearing(); + window.map.easeTo({ + pitch: pitch2d, + bearing: bearing2d, + zoom: mapAction.zoom !== undefined ? mapAction.zoom : window.map.getZoom(), + duration: 1500 + }); + showNotification('πŸ—ΊοΈ Map View', mapAction.mode === '2d' ? 'Switched to 2D top-down view' : 'Switched to 3D view', 'success'); + break; } } catch (error) { console.error('Map action execution error:', error); - console.error('Error details:', error.stack); - showNotification('❌ Map Error', `Could not execute map action: ${error.message}`, 'error'); + showNotification('❌ Map Error', `Action failed: ${error.message}`, 'error'); } } @@ -4052,6 +4427,12 @@ function initializeEVStationLayer() { // Try multiple ways to match the substation name let coords = providedCoords; + + // Normalize object coordinates to array if needed + if (coords && !Array.isArray(coords) && coords.lat && coords.lon) { + coords = [coords.lon, coords.lat]; + } + if (!coords) { const normalizedId = substationId.toLowerCase().replace(/[^a-z\s]/g, ''); coords = substationCoords[normalizedId] || substationCoords[substationId.toLowerCase()] || substationCoords[substationId]; @@ -4353,6 +4734,29 @@ function initializeEVStationLayer() { responseHtml += `
`; }); responseHtml += ``; + + // Downloadable file links (reports, snapshots) + const downloadableTools = aiResponse.tool_calls_made.filter( + tc => tc.success && (tc.url || tc.download_link || tc.server_json) + ); + if (downloadableTools.length > 0) { + responseHtml += `
`; + downloadableTools.forEach(tc => { + const url = tc.url || tc.download_link; + const jsonUrl = tc.server_json; + if (url) { + const fname = url.split('/').pop(); + const isPdf = fname.endsWith('.pdf'); + const icon = isPdf ? 'πŸ“„' : 'πŸ“Έ'; + const label = isPdf ? 'Download Report' : 'Download File'; + responseHtml += `${icon} ${label}`; + } + if (jsonUrl) { + responseHtml += `πŸ“‹ Download JSON`; + } + }); + responseHtml += `
`; + } } // Add suggestions if available @@ -4425,6 +4829,16 @@ function initializeEVStationLayer() { console.log("V2G Dashboard initialized (WebSocket mode)"); } + async function refreshV2GDashboard() { + try { + const res = await fetch('/api/v2g/status'); + const data = await res.json(); + if (data) updateV2GDashboard(data); + } catch (e) { + console.error('V2G refresh error:', e); + } + } + async function updateV2GDashboard(data) { if (!data) return; @@ -4432,14 +4846,20 @@ function initializeEVStationLayer() { // If data comes from network_state, it might be nested differently than the specific API response // But let's assume valid data for now or fallback safely - const activeSessions = data.v2g_sessions || data.active_sessions || []; + const activeSessions = data.active_sessions_list || data.v2g_sessions || []; const totalPower = data.v2g_total_power || data.total_power_kw || 0; const totalCars = data.v2g_vehicle_count || data.vehicles_participated || 0; const currentRate = data.v2g_rate || data.current_rate || 0.15; const totalEarnings = data.v2g_earnings || data.total_earnings || 0; + // DEBUG: Log V2G data for troubleshooting + console.log("V2G Update:", { active: activeSessions.length, power: totalPower, vehicles: totalCars }); + + // CRITICAL FIX: Ensure substation list is updated + updateV2GSubstationList(data); + // Update metrics with animation - updateWithAnimation('v2g-active-sessions', activeSessions.length || activeSessions); + updateWithAnimation('v2g-active-sessions', activeSessions.length); updateWithAnimation('v2g-power', totalPower); updateWithAnimation('v2g-vehicles', totalCars); updateWithAnimation('v2g-rate', `$${currentRate.toFixed(2)}`); @@ -5307,8 +5727,69 @@ function initializeEVStationLayer() { const visibility = map.getLayoutProperty('vehicles-symbols', 'visibility'); console.log(`Layer visibility: ${visibility || 'visible'}`); } - - console.log('=== END DEBUG INFO ==='); + +// --- Feature 2: Help UI Toggle --- +window.toggleHelp = function() { + const modal = document.getElementById('help-modal'); + if (modal.style.display === 'block') { + modal.style.display = 'none'; + } else { + modal.style.display = 'block'; + } +}; + +// Close modal when clicking outside +window.onclick = function(event) { + const modal = document.getElementById('help-modal'); + if (event.target === modal) { + modal.style.display = 'none'; + } +}; + +// --- Feature 4: Data Export --- +window.takeScreenshot = function() { + // Force a render first + map.triggerRepaint(); + const canvas = map.getCanvas(); + const dataURL = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = dataURL; + a.download = `manhattan_grid_snapshot_${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Show notification + const btn = document.querySelector('.help-footer .btn-secondary'); + const originalText = btn.innerText; + btn.innerText = "βœ… Saved!"; + setTimeout(() => btn.innerText = originalText, 2000); +}; + +window.exportState = function() { + // Fetch state from new API endpoint + fetch('/api/export-state') + .then(response => response.blob()) + .then(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `grid_state_${new Date().toISOString().slice(0,10)}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + + const btn = document.querySelector('.help-footer .btn-primary'); + const originalText = btn.innerText; + btn.innerText = "βœ… Exported!"; + setTimeout(() => btn.innerText = originalText, 2000); + }) + .catch(err => { + console.error('Export failed:', err); + alert('Export failed. See console.'); + }); +}; + return { hasLayer: !!hasLayer, diff --git a/static/styles.css b/static/styles.css index 01b9e29..bfbf032 100644 --- a/static/styles.css +++ b/static/styles.css @@ -2802,3 +2802,124 @@ body[data-tab="v2g"] .tab-v2g { max-height: calc(75vh - 24px); } } + +/* ========================================== + HELP MODAL & FLOATING BUTTON + ========================================== */ +.help-button { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: 50%; + background: linear-gradient(135deg, #00ff88, #00cc6a); + border: none; + color: #000; + font-size: 24px; + font-weight: bold; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 255, 136, 0.4); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.help-button:hover { + transform: scale(1.1) rotate(10deg); + box-shadow: 0 6px 16px rgba(0, 255, 136, 0.6); +} + +.help-modal { + display: none; /* Hidden by default */ + position: fixed; + z-index: 2001; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.7); + backdrop-filter: blur(5px); + animation: fadeIn 0.3s; +} + +.help-content { + background: rgba(20, 20, 30, 0.95); + border: 1px solid rgba(255, 255, 255, 0.1); + margin: 10% auto; + padding: 32px; + border-radius: 16px; + width: 80%; + max-width: 600px; + box-shadow: 0 20px 50px rgba(0,0,0,0.5); + position: relative; + color: #e0e0e0; +} + +.close-help { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + transition: color 0.2s; +} + +.close-help:hover, +.close-help:focus { + color: #fff; + text-decoration: none; +} + +.help-modal h2 { + margin-top: 0; + color: #00ff88; + border-bottom: 1px solid rgba(255,255,255,0.1); + padding-bottom: 16px; + margin-bottom: 24px; +} + +.help-section { + margin-bottom: 24px; +} + +.help-section h3 { + font-size: 16px; + color: #fff; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.help-section ul { + list-style-type: none; + padding: 0; +} + +.help-section li { + margin-bottom: 8px; + font-size: 14px; + color: rgba(255,255,255,0.7); + line-height: 1.5; +} + +.help-section li b { + color: #00ff88; +} + +.help-footer { + border-top: 1px solid rgba(255,255,255,0.1); + padding-top: 24px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +@keyframes fadeIn { + from {opacity: 0} + to {opacity: 1} +} diff --git a/ultra_intelligent_chatbot.py b/ultra_intelligent_chatbot.py index 63bcfdf..8495b6b 100644 --- a/ultra_intelligent_chatbot.py +++ b/ultra_intelligent_chatbot.py @@ -578,30 +578,28 @@ def __init__(self, integrated_system, ml_engine, v2g_manager, flask_app): print("[ULTRA CHATBOT] Initialized with MAXIMUM conversational intelligence!") async def _update_system_state(self): - """Update system state from backend APIs""" + """Update system state from the integrated system and V2G manager""" try: - import requests - - # Get current system status - system_response = requests.get("http://127.0.0.1:5000/api/status", timeout=5) - v2g_response = requests.get("http://127.0.0.1:5000/api/v2g/status", timeout=5) + substations = self.integrated_system.get_network_state()['substations'] - if system_response.status_code == 200: - system_data = system_response.json() - substations = system_data.get('substations', {}) - - self.system_state['substations'] = substations + self.system_state['substations'] = substations + if isinstance(substations, list): + self.system_state['failed_substations'] = [ + s['name'] for s in substations + if not s.get('operational', True) + ] + else: self.system_state['failed_substations'] = [ name for name, info in substations.items() - if info.get('status') == 'failed' + if not info.get('operational') ] - if v2g_response.status_code == 200: - v2g_data = v2g_response.json() + if self.v2g_manager: + v2g_data = self.v2g_manager.get_v2g_dashboard_data() self.system_state['v2g_enabled_substations'] = v2g_data.get('enabled_substations', []) self.system_state['last_updated'] = datetime.now() - print(f"[ULTRA CHATBOT] System updated: {len(self.system_state['failed_substations'])} failed substations, {len(self.system_state['v2g_enabled_substations'])} V2G enabled") + print(f"[ULTRA CHATBOT] System updated: {len(self.system_state['failed_substations'])} failed substations, {len(self.system_state.get('v2g_enabled_substations', []))} V2G enabled") except Exception as e: print(f"[ULTRA CHATBOT] Failed to update system state: {e}") @@ -1041,7 +1039,7 @@ async def _generate_ultra_intelligent_response_enhanced(self, original_input: st def _needs_confirmation(self, intent: str, entities: Dict[str, Any], corrected_input: str) -> bool: """Determine if an action needs user confirmation""" - # Critical actions that need confirmation + # Critical actions that need confirmation - ONLY for substation_control critical_intents = ['substation_control'] if intent in critical_intents: @@ -1050,10 +1048,12 @@ def _needs_confirmation(self, intent: str, entities: Dict[str, Any], corrected_i if any(action_word in action for action_word in critical_actions): return True - # Also check for potentially destructive keywords - destructive_keywords = ['shut down', 'turn off', 'disable', 'fail', 'stop', 'kill'] - if any(keyword in corrected_input.lower() for keyword in destructive_keywords): - return True + # Destructive keywords only apply to substation_control (avoid "has failed" in V2G context) + destructive_keywords = ['shut down', 'turn off', 'disable', 'stop', 'kill'] + if any(keyword in corrected_input.lower() for keyword in destructive_keywords): + return True + if 'fail' in corrected_input.lower() and not re.search(r'\b(has|have|is|are|was|were)\s+failed\b', corrected_input.lower()): + return True return False @@ -1556,8 +1556,8 @@ def levenshtein_distance(s1, s2): print(f"[ULTRA CHATBOT] Fuzzy match: '{matched_text}' -> '{location}' (distance: {distance})") # Extract action entities with NATURAL LANGUAGE UNDERSTANDING - # Turn off/disable/fail synonyms - turn_off_variants = ['turn off', 'turn of', 'disable', 'fail', 'shut down', 'shut off', + # Turn off/disable/fail synonyms - EXCLUDE "has failed" (state description) vs "fail" (action) + turn_off_variants = ['turn off', 'turn of', 'disable', 'shut down', 'shut off', 'switch off', 'power down', 'take down', 'offline', 'stop', 'disconnect'] # Turn on/enable/restore synonyms @@ -1574,10 +1574,13 @@ def levenshtein_distance(s1, s2): # Deactivate/stop synonyms deactivate_variants = ['deactivate', 'stop', 'halt', 'shutdown', 'turn off', 'disable', 'cease', 'end'] - # Check for turn off actions + # Check for turn off actions - 'fail' only when used as imperative, NOT in "has failed"/"is failed" if any(variant in text_lower for variant in turn_off_variants): entities['action'] = 'turn_off' print(f"[ULTRA CHATBOT] Detected turn_off action from: {[v for v in turn_off_variants if v in text_lower]}") + elif re.search(r'\bfail\b', text_lower) and not re.search(r'\b(has|have|is|are|was|were)\s+(failed|fail)\b', text_lower): + entities['action'] = 'turn_off' + print(f"[ULTRA CHATBOT] Detected turn_off action from: 'fail' (imperative)") # Check for turn on actions elif any(variant in text_lower for variant in turn_on_variants): @@ -2137,23 +2140,22 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> """Execute V2G commands - Can activate for any substation or all failed substations""" try: - import requests + if not self.v2g_manager: + return { + 'success': False, + 'text': 'V2G Manager is not available.', + } failed_substations = self.system_state.get('failed_substations', []) v2g_enabled = self.system_state.get('v2g_enabled_substations', []) if entities.get('action') == 'activate' or 'activate' in command: - # Check for "all" keyword to activate V2G for all failed substations activate_all = any(word in command.lower() for word in ['all', 'every', 'everything', 'all failed']) if activate_all and failed_substations: - # Activate V2G for ALL failed substations activated_substations = [] for sub_name in failed_substations: - api_url = f"http://127.0.0.1:5000/api/v2g/enable/{sub_name}" - print(f"[ULTRA CHATBOT] Activating V2G for {sub_name}: {api_url}") - response = requests.post(api_url, timeout=10) - if response.status_code == 200: + if self.v2g_manager.enable_v2g_for_substation(sub_name): activated_substations.append(sub_name) if activated_substations: @@ -2170,9 +2172,7 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> 'backend_executed': True } - # Check if there are failed substations that can use V2G if not failed_substations: - # Allow V2G activation even for operational substations (for testing/demo) if entities.get('location_data'): target_substation = entities['location_data']['substation'] return { @@ -2189,30 +2189,18 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> 'backend_executed': True } - # Determine which substation to activate V2G for target_substation = None - if entities.get('location_data'): - substation_name = entities['location_data']['substation'] - target_substation = substation_name # Allow any substation, not just failed ones - - # If no specific substation mentioned, use the first failed one - if not target_substation and failed_substations: + # Prefer failed substations when user says "trigger v2g" without specifying one + if failed_substations: target_substation = failed_substations[0] + if not target_substation and entities.get('location_data'): + target_substation = entities['location_data']['substation'] if target_substation: - # Use the correct V2G enable endpoint for specific substation - api_url = f"http://127.0.0.1:5000/api/v2g/enable/{target_substation}" - print(f"[ULTRA CHATBOT] Activating V2G for failed substation: {api_url}") - response = requests.post(api_url, timeout=10) - - if response.status_code == 200: - result = response.json() - print(f"[ULTRA CHATBOT] V2G API response: {result}") - + if self.v2g_manager.enable_v2g_for_substation(target_substation): return { 'success': True, 'text': f"**V2G ACTIVATED FOR {target_substation.upper()}**! Electric vehicles are being dispatched to provide emergency power to the failed substation. V2G toggle enabled on control panel.", - 'v2g_result': result, 'system_changes': [f"V2G enabled for {target_substation}", "V2G toggle activated"], 'backend_executed': True, 'map_action': { @@ -2225,7 +2213,7 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> else: return { 'success': False, - 'text': f"ERROR: Failed to activate V2G for {target_substation}. Backend error: {response.status_code}. Check if substation is actually failed.", + 'text': f"ERROR: Failed to activate V2G for {target_substation}. Check if substation is actually failed.", 'backend_error': True } else: @@ -2236,7 +2224,6 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> } elif entities.get('action') == 'deactivate' or any(word in command for word in ['deactivate', 'disable', 'stop', 'shutdown', 'turn off']): - # Handle V2G deactivation if not v2g_enabled: return { 'success': True, @@ -2245,46 +2232,29 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> 'backend_executed': True } - # Determine which substation to deactivate V2G for target_substation = None if entities.get('location_data'): substation_name = entities['location_data']['substation'] if substation_name in v2g_enabled: target_substation = substation_name - # If no specific substation mentioned, use the first enabled one if not target_substation and v2g_enabled: target_substation = v2g_enabled[0] if target_substation: - # Use the V2G disable endpoint for specific substation - api_url = f"http://127.0.0.1:5000/api/v2g/disable/{target_substation}" - print(f"[ULTRA CHATBOT] Deactivating V2G for substation: {api_url}") - response = requests.post(api_url, timeout=10) - - if response.status_code == 200: - result = response.json() - print(f"[ULTRA CHATBOT] V2G Disable API response: {result}") - - return { - 'success': True, - 'text': f"**V2G DEACTIVATED FOR {target_substation.upper()}**! Electric vehicles have been released and are no longer providing emergency power. V2G toggle disabled on control panel.", - 'v2g_result': result, - 'system_changes': [f"V2G disabled for {target_substation}", "V2G toggle deactivated"], - 'backend_executed': True, - 'map_action': { - 'type': 'highlight_v2g_deactivation', - 'substation': target_substation, - 'coordinates': entities.get('location_data', {}).get('coords', [-73.9857, 40.7580]), - 'name': f"{target_substation} V2G Deactivated" - } - } - else: - return { - 'success': False, - 'text': f"ERROR: Failed to deactivate V2G for {target_substation}. Backend error: {response.status_code}.", - 'backend_error': True + self.v2g_manager.disable_v2g_for_substation(target_substation) + return { + 'success': True, + 'text': f"**V2G DEACTIVATED FOR {target_substation.upper()}**! Electric vehicles have been released and are no longer providing emergency power. V2G toggle disabled on control panel.", + 'system_changes': [f"V2G disabled for {target_substation}", "V2G toggle deactivated"], + 'backend_executed': True, + 'map_action': { + 'type': 'highlight_v2g_deactivation', + 'substation': target_substation, + 'coordinates': entities.get('location_data', {}).get('coords', [-73.9857, 40.7580]), + 'name': f"{target_substation} V2G Deactivated" } + } else: return { 'success': False, @@ -2293,52 +2263,41 @@ async def _execute_v2g_command(self, command: str, entities: Dict[str, Any]) -> } else: - # Get V2G status and explain system intelligently - api_url = "http://127.0.0.1:5000/api/v2g/status" - print(f"[ULTRA CHATBOT] Calling V2G status API: {api_url}") - response = requests.get(api_url, timeout=10) - - if response.status_code == 200: - status = response.json() - - active_sessions = status.get('active_sessions', 0) - total_power = status.get('total_power_kw', 0) - total_earnings = status.get('total_earnings', 0) - enabled_substations = status.get('enabled_substations', []) + status = self.v2g_manager.get_v2g_dashboard_data() - if not failed_substations: - status_text = f" **V2G SYSTEM STANDBY** - All {len(self.system_state['substations'])} substations operational. V2G ready for emergency deployment if any substation fails." - elif enabled_substations: - status_text = f" **V2G EMERGENCY ACTIVE** - Responding to {len(enabled_substations)} failed substations: {', '.join(enabled_substations)}. {active_sessions} vehicles providing {total_power:.1f}kW emergency power." - else: - status_text = f"**V2G NEEDED** - {len(failed_substations)} substations failed ({', '.join(failed_substations)}) but V2G not yet activated. Emergency power backup available." + active_sessions = status.get('active_sessions_count', 0) + total_power = status.get('total_power_kw', 0) + total_earnings = status.get('total_earnings', 0) + enabled_substations = status.get('enabled_substations', []) - return { - 'success': True, - 'text': status_text + f"\\n\\n**Current Stats**: {active_sessions} vehicles active - ${total_earnings:.2f} earned - Rate: ${status.get('current_rate', 0):.2f}/kWh", - 'v2g_status': status, - 'system_context': { - 'failed_substations': failed_substations, - 'v2g_enabled': enabled_substations, - 'total_substations': len(self.system_state['substations']) - }, - 'backend_executed': True - } + if not failed_substations: + status_text = f" **V2G SYSTEM STANDBY** - All {len(self.system_state['substations'])} substations operational. V2G ready for emergency deployment if any substation fails." + elif enabled_substations: + status_text = f" **V2G EMERGENCY ACTIVE** - Responding to {len(enabled_substations)} failed substations: {', '.join(enabled_substations)}. {active_sessions} vehicles providing {total_power:.1f}kW emergency power." else: - return { - 'success': False, - 'text': f"ERROR: Failed to get V2G status. Backend error: {response.status_code}", - 'backend_error': True - } + status_text = f"**V2G NEEDED** - {len(failed_substations)} substations failed ({', '.join(failed_substations)}) but V2G not yet activated. Emergency power backup available." + + return { + 'success': True, + 'text': status_text + f"\\n\\n**Current Stats**: {active_sessions} vehicles active - ${total_earnings:.2f} earned - Rate: ${status.get('current_rate', 0):.2f}/kWh", + 'v2g_status': status, + 'system_context': { + 'failed_substations': failed_substations, + 'v2g_enabled': enabled_substations, + 'total_substations': len(self.system_state['substations']) + }, + 'backend_executed': True + } except Exception as e: - print(f"[ULTRA CHATBOT] V2G API error: {str(e)}") + print(f"[ULTRA CHATBOT] V2G command error: {str(e)}") return { 'success': False, - 'text': f"ERROR: Could not execute V2G command: {str(e)}. Check if the backend server is running.", + 'text': f"ERROR: Could not execute V2G command: {str(e)}.", 'error': str(e) } + async def _execute_analysis_command(self, command: str, entities: Dict[str, Any]) -> Dict[str, Any]: """Execute system analysis with REAL backend data""" @@ -3037,6 +2996,18 @@ def _handle_map_command(self, user_input: str) -> Optional[Dict[str, Any]]: 'intent': 'map_control' } + elif any(phrase in text_lower for phrase in ['switch to 2d', '2d map', '2d view', 'flat map', 'top down', 'switch to 2d map']): + return { + 'success': True, + 'text': 'Switching to 2D top-down view', + 'map_action': { + 'type': 'set_map_view', + 'mode': '2d', + 'pitch': 0 + }, + 'intent': 'map_control' + } + # No map command detected return None diff --git a/v2g_manager.py b/v2g_manager.py index cad0ef9..2ab59de 100644 --- a/v2g_manager.py +++ b/v2g_manager.py @@ -55,8 +55,11 @@ class V2GManager: # ========================================== # REALISTIC POWER SPECIFICATIONS # ========================================== - MIN_SOC_FOR_V2G = 0.60 # 60% minimum to participate - MAX_DISCHARGE_SOC = 0.30 # Don't discharge below 30% + # ========================================== + # REALISTIC POWER SPECIFICATIONS + # ========================================== + MIN_SOC_FOR_V2G = 0.20 # Lowered to 20% for demo purposes + MAX_DISCHARGE_SOC = 0.10 # Discharge down to 10% # Realistic V2G discharge rates (kW) DISCHARGE_RATE_LEVEL_1 = 7.2 # Level 1 V2G (home outlet equivalent) @@ -77,7 +80,7 @@ class V2GManager: # ========================================== MIN_DISCHARGE_DURATION_SECONDS = 0 # No minimum duration MIN_ENERGY_PER_VEHICLE_KWH = 0 # No minimum energy requirement - RESTORATION_ENERGY_THRESHOLD_KWH = 25 # Need 25 kWh total for restoration (faster recovery with 120x multiplier) + RESTORATION_ENERGY_THRESHOLD_KWH = 2000 # Need 2000 kWh total for restoration (approx 40-60s with 50 vehicles) MAX_V2G_VEHICLES = 50 # Maximum 50 vehicles simultaneously for FAST scenario completion def __init__(self, integrated_system, sumo_manager): @@ -308,6 +311,7 @@ def _broadcast_v2g_opportunity(self, substation_name: str): # Check SOC requirement if vehicle.config.current_soc < self.MIN_SOC_FOR_V2G: + # print(f"[V2G DEBUG] Skipped {vehicle.id}: Low SOC ({vehicle.config.current_soc:.2f})") continue # Skip if already occupied @@ -316,6 +320,7 @@ def _broadcast_v2g_opportunity(self, substation_name: str): vehicle.id in self.pending_v2g_vehicles or (hasattr(vehicle, 'is_charging') and vehicle.is_charging) or (hasattr(vehicle, 'assigned_ev_station') and vehicle.assigned_ev_station)): + # print(f"[V2G DEBUG] Skipped {vehicle.id}: Busy") continue if vehicle.id in traci.vehicle.getIDList(): @@ -500,10 +505,15 @@ def update_v2g_sessions(self): continue # Visual feedback - if vehicle_id in traci.vehicle.getIDList(): - traci.vehicle.setSpeed(vehicle_id, 0) - current_edge = traci.vehicle.getRoadID(vehicle_id) - traci.vehicle.setRoute(vehicle_id, [current_edge]) + if vehicle_id in traci.vehicle.getIDList(): + traci.vehicle.setSpeed(vehicle_id, 0) + try: + current_edge = traci.vehicle.getRoadID(vehicle_id) + if current_edge and not current_edge.startswith(':'): + traci.vehicle.setRoute(vehicle_id, [current_edge]) + except Exception: + pass + # Pulsing cyan pulse = int(time.time() * 4) % 4 @@ -598,7 +608,55 @@ def update_v2g_sessions(self): # Update peak power if total_power_provided > self.stats['peak_power_provided_kw']: self.stats['peak_power_provided_kw'] = total_power_provided - + + # CONTINUOUS RECRUITMENT: Try to find new vehicles every few updates + # This ensures vehicles that spawn later or move into range are caught + if int(time.time()) % 5 == 0: # Every ~5 seconds (real time) + for substation_name in list(self.v2g_enabled_substations): + self._broadcast_v2g_opportunity(substation_name) + + # PROCESS PENDING VEHICLES (Transition to Active) + # Vehicles that were assigned but haven't started discharging yet + import traci + for vehicle_id in list(self.pending_v2g_vehicles.keys()): + if vehicle_id not in self.sumo_manager.vehicles: + # Vehicle left simulation + del self.pending_v2g_vehicles[vehicle_id] + self.v2g_locked_vehicles.discard(vehicle_id) + continue + + # Check if vehicle has stopped (arrived at V2G spot) + try: + vehicle = self.sumo_manager.vehicles.get(vehicle_id) + if not vehicle: + continue + + if vehicle_id in traci.vehicle.getIDList(): + speed = traci.vehicle.getSpeed(vehicle_id) + current_edge = traci.vehicle.getRoadID(vehicle_id) + + # Check if arrived: Stopped OR on target edge + # We use a loose check to ensure the demo feels responsive + target_station = getattr(vehicle, 'v2g_station', None) + station_edge = None + if target_station and self.sumo_manager.station_manager: + st = self.sumo_manager.station_manager.stations.get(target_station) + if st: station_edge = st.get('edge') + + is_stopped = speed < 0.5 + on_target_edge = (station_edge and current_edge == station_edge) + + if is_stopped or on_target_edge: + # Activate session! + substation_id = self.pending_v2g_vehicles[vehicle_id] + # Retrieve the specific station ID assigned during recruitment + station_id = getattr(vehicle, 'v2g_station', 'virtual_station') + + self.start_v2g_session(vehicle_id, station_id, substation_id) + print(f"[V2G] πŸš— Vehicle {vehicle_id} arrived at {station_id} and STARTED discharging!") + except Exception as e: + print(f"[V2G] Error checking pending vehicle {vehicle_id}: {e}") + # Update average discharge rate if self.active_sessions: total_rate = sum(s.actual_power_kw for s in self.active_sessions.values()) @@ -830,9 +888,21 @@ def get_v2g_dashboard_data(self) -> Dict: return { 'enabled_substations': list(self.v2g_enabled_substations), + 'substations_with_v2g': list(self.v2g_enabled_substations), # Alias for agentic tools 'restored_substations': list(self.restored_substations), 'recently_restored_substations': recent_restored_names, - 'active_sessions': len(self.active_sessions), + 'active_sessions_count': len(self.active_sessions), + 'active_sessions_list': [ + { + 'id': s.session_id, + 'vehicle_id': s.vehicle_id, + 'substation': s.substation_id, + 'power_kw': self.DISCHARGE_RATE_KW, + 'earnings': s.earnings, + 'status': 'discharging' + } + for s in self.active_sessions.values() + ], 'locked_vehicles': len(self.v2g_locked_vehicles), 'pending_vehicles': len(self.pending_v2g_vehicles), 'total_power_kw': active_power, @@ -852,7 +922,7 @@ def get_v2g_dashboard_data(self) -> Dict: 'average_discharge_rate': self.stats.get('average_discharge_rate_kw', 0), 'peak_power': self.stats['peak_power_provided_kw'], 'total_discharge_minutes': self.stats.get('total_discharge_time_minutes', 0), - 'active_vehicles': [ + 'active_sessions': [ { 'vehicle_id': v_id, 'id': v_id, # Add 'id' as alias @@ -871,4 +941,8 @@ def get_v2g_dashboard_data(self) -> Dict: for v_id, session in self.active_sessions.items() if v_id in self.sumo_manager.vehicles ] - } \ No newline at end of file + } + + def get_v2g_status(self): + """Alias for compatibility with chatbot/API""" + return self.get_v2g_dashboard_data() \ No newline at end of file