Dhaerya commited on
Commit
b00d5d5
·
1 Parent(s): 584ff8c

Add files

Browse files
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ .ipynb_checkpoints
5
+ frontend/node_modules
6
+ frontend/dist
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ .DS_Store
11
+ .env
12
+ results/
13
+ logs/
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─────────────────────────────────────────────
2
+ # Python
3
+ # ─────────────────────────────────────────────
4
+ __pycache__/
5
+ *.py[cod]
6
+ *.pyo
7
+ *.pyd
8
+ .Python
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ *.egg
13
+
14
+ # ─────────────────────────────────────────────
15
+ # Virtual environments
16
+ # ─────────────────────────────────────────────
17
+ venv/
18
+ .venv/
19
+ env/
20
+
21
+ # ─────────────────────────────────────────────
22
+ # Project outputs (keep directory structure)
23
+ # ─────────────────────────────────────────────
24
+ models/*.pth
25
+ models/*.npy
26
+ results/logs/*.log
27
+ results/plots/*.png
28
+ results/checkpoints/*.pth
29
+ results/metrics.json
30
+
31
+ # ─────────────────────────────────────────────
32
+ # IDE / OS
33
+ # ─────────────────────────────────────────────
34
+ .idea/
35
+ .vscode/
36
+ *.DS_Store
37
+ Thumbs.db
Architecture.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Traffic Control Reinforcement Learning Architecture
2
+
3
+ This document provides a comprehensive A-Z breakdown of the Reinforcement Learning (RL) architecture implemented in the Traffic Signal Control project.
4
+
5
+ The system replaces traditional fixed-timing traffic lights with intelligent, adaptive agents that learn optimal signal switching policies. **It features a full-stack React and FastAPI Web Dashboard** to visualize the intersection in real-time.
6
+
7
+ ---
8
+
9
+ ## 1. High-Level System Flow
10
+
11
+ The architecture follows the standard Reinforcement Learning MDP (Markov Decision Process) loop:
12
+
13
+ 1. **Observe**: The Agent receives the current **State** (queue lengths for 8 lanes, current light phase) from the Environment.
14
+ 2. **Decide**: The Agent selects an **Action** (keep the current phase OR switch to the next phase).
15
+ 3. **Act**: The Environment executes the action, simulating traffic flow for a specific duration.
16
+ 4. **Learn**: The Environment returns a **Reward** (penalty based on total waiting traffic). The Agent uses this to update its neural network.
17
+
18
+ ---
19
+
20
+ ## 2. The Environment (`TrafficEnvironment`)
21
+
22
+ The environment is the simulated 8-lane intersection built using the `Gymnasium` API.
23
+
24
+ ### 2.1 State Space (Observation)
25
+ We use a continuous **9-dimensional state vector** to perfectly track Straight/Right (SR) and Left-Turn (L) queues:
26
+ * `[N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L, current_phase]`
27
+ * **Absolute Normalization**: Queue lengths are normalized linearly by dividing by `20.0` and clipping to `[0, 1]`. This ensures the neural network correctly perceives absolute traffic volume.
28
+ * **Phase Representation**: The current phase (0 to 3) is normalized to `phase / 3.0`.
29
+
30
+ ### 2.2 Action Space & 4-Phase Cycle
31
+ The agent has a discrete action space of size 2 (Keep or Switch). It cycles through **4 Directional Phases** to completely eliminate turning collisions:
32
+ * `Phase 0`: North Green (Straight + Left)
33
+ * `Phase 1`: East Green (Straight + Left)
34
+ * `Phase 2`: South Green (Straight + Left)
35
+ * `Phase 3`: West Green (Straight + Left)
36
+
37
+ ### 2.3 Reward Function
38
+ * **Calculation**: `reward = -(Total Queue Length) / 20.0`
39
+ * **Clipping**: The reward is hard-clipped to `[-1.0, 1.0]`.
40
+ * **Sensitivity**: Dividing by 20.0 ensures the agent receives strong gradient signals even in low-density traffic conditions, forcing it to actively clear small queues.
41
+
42
+ ---
43
+
44
+ ## 3. Traffic Generation (`TrafficGenerator`)
45
+
46
+ * **Realistic Volume**: The traffic density is tuned to produce roughly 2,000–4,000 total arrivals per episode.
47
+ * **Left-Turn Probabilities**: Roughly 20% of generated traffic is routed into the dedicated Left-Turn queues.
48
+ * **Stochastic Bursts**: There is a 15% probability that a sudden "burst" of traffic will arrive in a random lane, testing the agent's adaptability.
49
+
50
+ ---
51
+
52
+ ## 4. The Deep Q-Network Agent (`DQNAgent`)
53
+
54
+ A modern Deep RL approach utilizing PyTorch.
55
+ * **Neural Network Architecture**:
56
+ `Input Layer (9)` -> `Hidden Layer (256, ReLU)` -> `Hidden Layer (256, ReLU)` -> `Output Layer (2, Keep/Switch)`
57
+ * **Experience Replay Buffer**: Transitions `(s, a, r, s', done)` are stored in a circular buffer (size: 50,000). The network trains by sampling random mini-batches (size 256).
58
+ * **Target Network**: Uses an Online and Target network synced every 10 episodes to stabilize training.
59
+ * **Hardware Acceleration**: Automatically utilizes CUDA (NVIDIA GPUs) for accelerated tensor operations.
60
+
61
+ ---
62
+
63
+ ## 5. Web Dashboard Architecture
64
+
65
+ The project features a beautiful full-stack simulation dashboard.
66
+
67
+ ### 5.1 Backend (`backend/app.py`)
68
+ * Built with **FastAPI**.
69
+ * Loads the fully trained `dqn_best.pth` model into memory.
70
+ * Exposes `/api/reset` and `/api/step` endpoints.
71
+ * When the frontend calls `/api/step`, the backend asks the DQN agent for an action, steps the Python Gymnasium environment, and returns the 9D State and reward back to the UI.
72
+
73
+ ### 5.2 Frontend (`frontend/src/App.jsx`)
74
+ * Built with **React and Vite**.
75
+ * **Ultra-Premium Aesthetics**: Features a dark glassmorphism UI, a glowing neon background, and etched asphalt road textures.
76
+ * **Live Telemetry**: Tracks total throughput and RL reward signals in real-time.
77
+ * **CSS Animations**: Cars are dynamically rendered in all 8 lanes and visually animate driving straight or turning left (-90deg rotation) when their specific directional light turns green.
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- Frontend Build Stage ---
2
+ FROM node:20-slim AS frontend-builder
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package*.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ # --- Final Image Stage ---
10
+ FROM python:3.10-slim
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies
14
+ RUN apt-get update && apt-get install -y \
15
+ build-essential \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Copy requirements and install Python dependencies
19
+ COPY requirements.txt .
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Copy the rest of the application
23
+ COPY . .
24
+
25
+ # Copy the built frontend from the builder stage
26
+ COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
27
+
28
+ # Expose port 7860 (Hugging Face default)
29
+ EXPOSE 7860
30
+
31
+ # Command to run the application
32
+ # We run from the backend directory to ensure app:app pathing is correct
33
+ CMD ["python", "backend/app.py"]
README copy.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RL Traffic Signal Control
2
+
3
+ An advanced Reinforcement Learning implementation for adaptive traffic signal control, featuring a **9-Dimensional Deep Q-Network (DQN)** and an **Ultra-Premium Full-Stack React Dashboard**.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ 1. **8-Lane Intersection Simulation**: Handles Straight/Right and dedicated Left-Turn queues for North, East, South, and West directions.
10
+ 2. **4-Phase Directional System**: Eliminates turning collisions by dedicating phases (North-Only, East-Only, etc.).
11
+ 3. **Deep Q-Network**: A PyTorch-based RL agent that dynamically switches lights to keep traffic queues at absolute minimums.
12
+ 4. **Full-Stack Dashboard**: A FastAPI backend serves the RL model to a stunning Vite+React frontend featuring live telemetry and CSS vehicle animations.
13
+
14
+ ---
15
+
16
+ ## Quick Start (Web Dashboard)
17
+
18
+ To view the simulation visually, you need to run the Backend and the Frontend simultaneously.
19
+
20
+ ### 1. Start the FastAPI Backend
21
+ Open a terminal and run:
22
+ ```bash
23
+ cd "Traffic Control/backend"
24
+ ..\.venv\Scripts\python -m uvicorn app:app --reload
25
+ ```
26
+
27
+ ### 2. Start the React Frontend
28
+ Open a **second** terminal and run:
29
+ ```bash
30
+ cd "Traffic Control/frontend"
31
+ npm run dev
32
+ ```
33
+
34
+ Open your browser to `http://localhost:5173`, hit **Play**, and watch the DQN manage the intersection in real-time!
35
+
36
+ ---
37
+
38
+ ## Command Line Training Pipeline
39
+
40
+ If you want to completely retrain the Deep Q-Network from scratch:
41
+
42
+ ```bash
43
+ # 1. Activate virtual environment
44
+ venv\Scripts\activate
45
+
46
+ # 2. Run the full automated training pipeline
47
+ python main.py --auto
48
+ ```
49
+
50
+ This will run 150 episodes, utilizing CUDA GPU acceleration if available, and automatically save the best performing model to `models/dqn_best.pth`.
51
+
52
+ ---
53
+
54
+ ## Project Structure
55
+
56
+ ```
57
+ Traffic Control/
58
+ ├── frontend/ # React + Vite Web Dashboard
59
+ │ ├── src/App.jsx # Premium Dashboard UI
60
+ │ └── src/App.css # Neon Glassmorphism Styles
61
+ ├── backend/
62
+ │ └── app.py # FastAPI Server mapping RL to HTTP
63
+ ├── agent/
64
+ │ └── dqn_agent.py # Deep Q-Network (PyTorch + GPU)
65
+ ├── environment/
66
+ │ ├── traffic_env.py # Gymnasium environment (9D State)
67
+ │ └── traffic_generator.py # Stochastic traffic generator
68
+ ├── models/ # Saved agent models (.pth)
69
+ ├── results/ # Logs, metrics, and plots
70
+ ├── config.py # Neural network & simulation parameters
71
+ └── main.py # Training entry point
72
+ ```
73
+
74
+ ---
75
+
76
+ ## How It Works (RL Mechanics)
77
+
78
+ ### The Environment
79
+ - **State**: `[N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L, current_phase]` (9 features, absolutely normalized to 0.0-1.0)
80
+ - **Actions**: `0` = keep current phase · `1` = switch to next directional phase
81
+ - **Reward**: `−total_queue / 20.0`, clipped to `[−1, 1]`
82
+
83
+ ### The Model
84
+ A multi-layer perceptron `Input(9) → Linear(256) → ReLU → Linear(256) → ReLU → Linear(2)` trained with experience replay (50,000-transition buffer) and target-network updates. The model aggressively seeks to clear queues to maximize its reward score.
agent/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent package.
3
+
4
+ Provides:
5
+ BaseAgent — abstract interface
6
+ QLearningAgent — tabular Q-learning (NumPy only)
7
+ DQNAgent — Deep Q-Network (PyTorch, GPU-accelerated)
8
+ """
9
+
10
+ from .base_agent import BaseAgent
11
+ from .q_learning_agent import QLearningAgent
12
+
13
+ # DQN requires PyTorch
14
+ try:
15
+ from .dqn_agent import DQNAgent
16
+ DQN_AVAILABLE = True
17
+ except ImportError:
18
+ DQN_AVAILABLE = False
19
+ DQNAgent = None # type: ignore
20
+
21
+ __all__ = ["BaseAgent", "QLearningAgent", "DQNAgent"]
agent/base_agent.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BaseAgent — abstract interface that all RL agents must implement.
3
+
4
+ Every concrete agent (Q-Learning, DQN, …) must override:
5
+ • select_action(state, training) → int
6
+ • train_step(state, action, reward, next_state, done) → float | None
7
+ • save(filepath)
8
+ • load(filepath)
9
+
10
+ Optional hooks:
11
+ • update_target_network() – used by DQN
12
+ • reset() – called between episodes if needed
13
+ """
14
+
15
+ from abc import ABC, abstractmethod
16
+
17
+ try:
18
+ import torch
19
+ _TORCH_AVAILABLE = True
20
+ except ImportError:
21
+ _TORCH_AVAILABLE = False
22
+
23
+
24
+ class BaseAgent(ABC):
25
+ """Abstract base class for RL agents."""
26
+
27
+ def __init__(self, state_size: int, action_size: int, config: dict):
28
+ self.state_size = state_size
29
+ self.action_size = action_size
30
+ self.config = config
31
+
32
+ if _TORCH_AVAILABLE:
33
+ import torch
34
+ self.device = torch.device(
35
+ "cuda" if torch.cuda.is_available() else "cpu"
36
+ )
37
+ else:
38
+ self.device = None
39
+
40
+ @abstractmethod
41
+ def select_action(self, state, training: bool = True) -> int:
42
+ """Return an action integer for the given state."""
43
+
44
+ @abstractmethod
45
+ def train_step(self, state, action, reward, next_state, done):
46
+ """Perform one update step; return loss (or None if not applicable)."""
47
+
48
+ @abstractmethod
49
+ def save(self, filepath: str):
50
+ """Persist the agent to *filepath*."""
51
+
52
+ @abstractmethod
53
+ def load(self, filepath: str):
54
+ """Restore the agent from *filepath*."""
55
+
56
+ # Optional hooks
57
+ def update_target_network(self):
58
+ """Sync target network (DQN only)."""
59
+
60
+ def reset(self):
61
+ """Reset any per-episode internal state."""
agent/dqn_agent.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deep Q-Network (DQN) Agent — PyTorch implementation with GPU support.
3
+
4
+ Architecture:
5
+ Input(5) → Linear(128) → ReLU → Linear(128) → ReLU → Linear(2)
6
+
7
+ Training improvements (from PROJECT_EXPLANATION.md):
8
+ • Experience replay buffer (50 000 transitions) breaks temporal correlations.
9
+ • Target network updated every 10 episodes for stable targets.
10
+ • Low learning rate (0.0001) prevents oscillation.
11
+ • Slow epsilon decay (0.998/step) for thorough exploration.
12
+
13
+ Key results:
14
+ • Mean reward: −922.41 (0.1% better than fixed signal)
15
+ """
16
+
17
+ import random
18
+ from collections import deque
19
+
20
+ import numpy as np
21
+ import torch
22
+ import torch.nn as nn
23
+ import torch.optim as optim
24
+
25
+ from .base_agent import BaseAgent
26
+
27
+ # ── Device detection ──────────────────────────────────────────────────────────
28
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
29
+ print(f"[DQN] Using device: {DEVICE}")
30
+ if torch.cuda.is_available():
31
+ print(f"[DQN] GPU: {torch.cuda.get_device_name(0)}")
32
+
33
+
34
+ # ── Neural network ────────────────────────────────────────────────────────────
35
+
36
+ class QNetwork(nn.Module):
37
+ """Fully-connected Q-value approximator."""
38
+
39
+ def __init__(self, state_size: int, action_size: int, hidden_layers: list):
40
+ super().__init__()
41
+ layers = []
42
+ in_size = state_size
43
+ for h in hidden_layers:
44
+ layers += [nn.Linear(in_size, h), nn.ReLU()]
45
+ in_size = h
46
+ layers.append(nn.Linear(in_size, action_size))
47
+ self.net = nn.Sequential(*layers)
48
+
49
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
50
+ return self.net(x)
51
+
52
+
53
+ # ── Replay buffer ─────────────────────────────────────────────────────────────
54
+
55
+ class ReplayBuffer:
56
+ """Fixed-capacity circular experience replay buffer."""
57
+
58
+ def __init__(self, capacity: int):
59
+ self.buffer: deque = deque(maxlen=capacity)
60
+
61
+ def push(self, state, action, reward, next_state, done):
62
+ self.buffer.append((state, action, reward, next_state, done))
63
+
64
+ def sample(self, batch_size: int):
65
+ batch = random.sample(self.buffer, batch_size)
66
+ states, actions, rewards, next_states, dones = zip(*batch)
67
+ return states, actions, rewards, next_states, dones
68
+
69
+ def __len__(self) -> int:
70
+ return len(self.buffer)
71
+
72
+
73
+ # ── DQN Agent ─────────────────────────────────────────────────────────────────
74
+
75
+ class DQNAgent(BaseAgent):
76
+ """
77
+ DQN with experience replay, target network, and ε-greedy exploration.
78
+ Automatically uses GPU if available (CUDA).
79
+ """
80
+
81
+ def __init__(self, state_size: int, action_size: int, config: dict):
82
+ super().__init__(state_size, action_size, config)
83
+
84
+ # Hyperparameters
85
+ self.learning_rate = config["learning_rate"]
86
+ self.gamma = config["gamma"]
87
+ self.epsilon = config["epsilon_start"]
88
+ self.epsilon_end = config["epsilon_end"]
89
+ self.epsilon_decay = config["epsilon_decay"]
90
+ self.batch_size = config["batch_size"]
91
+ self.target_update_freq = config["target_update"] # episodes
92
+
93
+ hidden = config["hidden_layers"]
94
+
95
+ # Networks
96
+ self.q_net = QNetwork(state_size, action_size, hidden).to(DEVICE)
97
+ self.target_net = QNetwork(state_size, action_size, hidden).to(DEVICE)
98
+ self.target_net.load_state_dict(self.q_net.state_dict())
99
+ self.target_net.eval()
100
+
101
+ self.optimizer = optim.Adam(self.q_net.parameters(), lr=self.learning_rate)
102
+ self.criterion = nn.MSELoss()
103
+ self.memory = ReplayBuffer(config["memory_size"])
104
+
105
+ # Train every N environment steps (reduces CPU-GPU round-trip overhead)
106
+ self.train_frequency = config.get("train_frequency", 4)
107
+ self._step_counter = 0 # counts env steps since last gradient update
108
+
109
+ # Stats
110
+ self.steps = 0
111
+ self.episodes = 0
112
+
113
+ print(f"[DQN] Initialised state={state_size} actions={action_size} "
114
+ f"hidden={hidden} device={DEVICE} "
115
+ f"train_every={self.train_frequency}_steps batch={self.batch_size}")
116
+
117
+ # ------------------------------------------------------------------
118
+ # BaseAgent interface
119
+ # ------------------------------------------------------------------
120
+
121
+ def select_action(self, state, training: bool = True) -> int:
122
+ """ε-greedy action selection."""
123
+ if not isinstance(state, np.ndarray):
124
+ state = np.array(state, dtype=np.float32)
125
+ if state.dtype != np.float32:
126
+ state = state.astype(np.float32)
127
+
128
+ if training and random.random() < self.epsilon:
129
+ return random.randrange(self.action_size)
130
+
131
+ self.q_net.eval()
132
+ with torch.no_grad():
133
+ t = torch.FloatTensor(state).unsqueeze(0).to(DEVICE)
134
+ action = int(self.q_net(t).argmax().item())
135
+ self.q_net.train()
136
+ return action
137
+
138
+ def train_step(self, state, action, reward, next_state, done):
139
+ """
140
+ Store transition; run a gradient update every `train_frequency` steps.
141
+
142
+ Skipping gradient updates on most steps eliminates repeated CPU-GPU
143
+ data transfers for tiny batches — the dominant latency for small networks.
144
+
145
+ Returns:
146
+ loss (float) if a gradient step was taken, else None.
147
+ """
148
+ self.memory.push(state, int(action), float(reward), next_state, bool(done))
149
+ self._step_counter += 1
150
+
151
+ # Only train every N steps
152
+ if self._step_counter % self.train_frequency != 0:
153
+ return None
154
+
155
+ if len(self.memory) < self.batch_size:
156
+ return None
157
+
158
+ # Sample mini-batch
159
+ states, actions, rewards, next_states, dones = self.memory.sample(
160
+ self.batch_size
161
+ )
162
+
163
+ states_t = torch.FloatTensor(np.array(states)).to(DEVICE)
164
+ actions_t = torch.LongTensor(actions).to(DEVICE)
165
+ rewards_t = torch.FloatTensor(rewards).to(DEVICE)
166
+ next_states_t = torch.FloatTensor(np.array(next_states)).to(DEVICE)
167
+ dones_t = torch.FloatTensor([float(d) for d in dones]).to(DEVICE)
168
+
169
+ # Current Q-values
170
+ current_q = (
171
+ self.q_net(states_t)
172
+ .gather(1, actions_t.unsqueeze(1))
173
+ .squeeze(1)
174
+ )
175
+
176
+ # Target Q-values (Bellman)
177
+ with torch.no_grad():
178
+ next_q = self.target_net(next_states_t).max(1)[0]
179
+ target_q = rewards_t + (1.0 - dones_t) * self.gamma * next_q
180
+
181
+ loss = self.criterion(current_q, target_q)
182
+ self.optimizer.zero_grad()
183
+ loss.backward()
184
+ self.optimizer.step()
185
+
186
+ # Decay epsilon once per gradient step
187
+ self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)
188
+ self.steps += 1
189
+
190
+ return float(loss.item())
191
+
192
+ def update_target_network(self):
193
+ """Copy online-network weights into target network."""
194
+ self.target_net.load_state_dict(self.q_net.state_dict())
195
+
196
+ def save(self, filepath: str):
197
+ torch.save(
198
+ {
199
+ "q_net": self.q_net.state_dict(),
200
+ "target_net": self.target_net.state_dict(),
201
+ "optimizer": self.optimizer.state_dict(),
202
+ "epsilon": self.epsilon,
203
+ "steps": self.steps,
204
+ "episodes": self.episodes,
205
+ },
206
+ filepath,
207
+ )
208
+ print(f"[DQN] Model saved -> {filepath}")
209
+
210
+ def load(self, filepath: str):
211
+ ckpt = torch.load(filepath, map_location=DEVICE)
212
+ self.q_net.load_state_dict(ckpt["q_net"])
213
+ self.target_net.load_state_dict(ckpt["target_net"])
214
+ self.optimizer.load_state_dict(ckpt["optimizer"])
215
+ self.epsilon = ckpt["epsilon"]
216
+ self.steps = ckpt["steps"]
217
+ self.episodes = ckpt["episodes"]
218
+ print(f"[DQN] Model loaded <- {filepath} (episodes={self.episodes})")
agent/q_learning_agent.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tabular Q-Learning Agent.
3
+
4
+ Implements Q(s,a) ← Q(s,a) + α [r + γ·max_a' Q(s',a') − Q(s,a)]
5
+
6
+ Because Q-learning requires a finite state space, the continuous
7
+ observation is discretised into equal-width bins per dimension.
8
+
9
+ Key results from PROJECT_EXPLANATION.md:
10
+ • Mean reward: −916.97 (best among all methods)
11
+ • 5-feature state + 10 bins per dimension performs well
12
+ • Epsilon-greedy exploration with decay 0.995/episode
13
+ """
14
+
15
+ import numpy as np
16
+ from .base_agent import BaseAgent
17
+
18
+
19
+ class QLearningAgent(BaseAgent):
20
+ """
21
+ Tabular Q-Learning with adaptive state discretisation.
22
+
23
+ The Q-table is stored as a sparse dictionary
24
+ {(discrete_state_tuple, action): q_value} for memory efficiency.
25
+ """
26
+
27
+ def __init__(self, state_size: int, action_size: int, config: dict):
28
+ super().__init__(state_size, action_size, config)
29
+
30
+ # Hyperparameters
31
+ self.learning_rate = config.get("learning_rate", 0.1)
32
+ self.gamma = config.get("gamma", 0.99)
33
+ self.epsilon = config.get("epsilon_start", 1.0)
34
+ self.epsilon_end = config.get("epsilon_end", 0.01)
35
+ self.epsilon_decay = config.get("epsilon_decay", 0.995)
36
+ self.num_bins = config.get("num_bins", 10)
37
+
38
+ # Adaptive bounds for normalisation
39
+ self.state_mins = np.zeros(state_size, dtype=np.float32)
40
+ self.state_maxs = np.ones(state_size, dtype=np.float32)
41
+
42
+ # Sparse Q-table
43
+ self.q_table: dict = {}
44
+
45
+ # Stats
46
+ self.steps = 0
47
+ self.episodes = 0
48
+
49
+ print(f"[Q-Learning] Initialised state={state_size} "
50
+ f"actions={action_size} bins={self.num_bins} "
51
+ f"lr={self.learning_rate} gamma={self.gamma}")
52
+
53
+ # ------------------------------------------------------------------
54
+ # Helpers
55
+ # ------------------------------------------------------------------
56
+
57
+ def _discretise(self, state: np.ndarray) -> tuple:
58
+ """Convert continuous state → discrete tuple (hashable dict key)."""
59
+ if not isinstance(state, np.ndarray):
60
+ state = np.array(state, dtype=np.float32)
61
+ if state.dtype != np.float32:
62
+ state = state.astype(np.float32)
63
+
64
+ # Update running bounds
65
+ self.state_mins = np.minimum(self.state_mins, state)
66
+ self.state_maxs = np.maximum(self.state_maxs, state)
67
+ ranges = np.maximum(self.state_maxs - self.state_mins, 1e-8)
68
+
69
+ normalised = np.clip((state - self.state_mins) / ranges, 0.0, 1.0)
70
+ indices = (normalised * (self.num_bins - 1)).astype(np.int32)
71
+ return tuple(indices)
72
+
73
+ def _get_q(self, discrete_state: tuple, action: int) -> float:
74
+ return self.q_table.get((discrete_state, action), 0.0)
75
+
76
+ def _set_q(self, discrete_state: tuple, action: int, value: float):
77
+ self.q_table[(discrete_state, action)] = float(value)
78
+
79
+ # ------------------------------------------------------------------
80
+ # BaseAgent interface
81
+ # ------------------------------------------------------------------
82
+
83
+ def select_action(self, state, training: bool = True) -> int:
84
+ """Epsilon-greedy action selection."""
85
+ ds = self._discretise(state)
86
+
87
+ if training and np.random.random() < self.epsilon:
88
+ return int(np.random.randint(0, self.action_size))
89
+
90
+ q_values = [self._get_q(ds, a) for a in range(self.action_size)]
91
+ max_q = max(q_values)
92
+ best = [a for a, q in enumerate(q_values) if q == max_q]
93
+ return int(np.random.choice(best))
94
+
95
+ def train_step(self, state, action, reward, next_state, done):
96
+ """
97
+ One Bellman update.
98
+
99
+ Returns:
100
+ td_error (float): Temporal-difference error for this update.
101
+ """
102
+ ds = self._discretise(state)
103
+ dns = self._discretise(next_state)
104
+
105
+ action = int(action)
106
+ reward = float(reward)
107
+ done = bool(done)
108
+
109
+ current_q = self._get_q(ds, action)
110
+
111
+ if done:
112
+ target_q = reward
113
+ else:
114
+ next_qs = [self._get_q(dns, a) for a in range(self.action_size)]
115
+ target_q = reward + self.gamma * max(next_qs)
116
+
117
+ td_error = target_q - current_q
118
+ self._set_q(ds, action, current_q + self.learning_rate * td_error)
119
+
120
+ if done:
121
+ self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)
122
+ self.episodes += 1
123
+
124
+ self.steps += 1
125
+ return float(td_error)
126
+
127
+ def save(self, filepath: str):
128
+ """Serialise Q-table to a .npy file."""
129
+ payload = {
130
+ "q_table": dict(self.q_table),
131
+ "state_mins": self.state_mins.tolist(),
132
+ "state_maxs": self.state_maxs.tolist(),
133
+ "epsilon": self.epsilon,
134
+ "steps": self.steps,
135
+ "episodes": self.episodes,
136
+ "num_bins": self.num_bins,
137
+ }
138
+ np.save(filepath, payload, allow_pickle=True)
139
+ print(f"[Q-Learning] Saved Q-table ({len(self.q_table)} entries) -> {filepath}")
140
+
141
+ def load(self, filepath: str):
142
+ """Deserialise Q-table from a .npy file."""
143
+ payload = np.load(filepath, allow_pickle=True).item()
144
+ self.q_table = payload["q_table"]
145
+ self.state_mins = np.array(payload["state_mins"], dtype=np.float32)
146
+ self.state_maxs = np.array(payload["state_maxs"], dtype=np.float32)
147
+ self.epsilon = payload["epsilon"]
148
+ self.steps = payload["steps"]
149
+ self.episodes = payload["episodes"]
150
+ self.num_bins = payload["num_bins"]
151
+ print(f"[Q-Learning] Loaded Q-table ({len(self.q_table)} entries) <- {filepath}")
152
+
153
+ # ------------------------------------------------------------------
154
+ # Diagnostics
155
+ # ------------------------------------------------------------------
156
+
157
+ def stats(self) -> dict:
158
+ if not self.q_table:
159
+ return {"entries": 0, "unique_states": 0}
160
+ states = {s for s, _ in self.q_table}
161
+ vals = list(self.q_table.values())
162
+ return {
163
+ "entries": len(self.q_table),
164
+ "unique_states": len(states),
165
+ "mean_q": float(np.mean(vals)),
166
+ "max_q": float(np.max(vals)),
167
+ "min_q": float(np.min(vals)),
168
+ "epsilon": round(self.epsilon, 4),
169
+ "episodes": self.episodes,
170
+ }
171
+
172
+ def __repr__(self):
173
+ s = self.stats()
174
+ return (
175
+ f"QLearningAgent(state={self.state_size}, actions={self.action_size}, "
176
+ f"bins={self.num_bins}, entries={s['entries']}, ε={s['epsilon']})"
177
+ )
backend/app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ from pathlib import Path
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+
9
+ # Add parent directory to path so we can import the RL modules
10
+ ROOT_DIR = Path(__file__).parent.parent
11
+ sys.path.append(str(ROOT_DIR))
12
+
13
+ import config as cfg
14
+ from environment import TrafficEnvironment
15
+
16
+ # We will try to load DQN since it performed best
17
+ try:
18
+ from agent import DQNAgent
19
+ DQN_AVAILABLE = True
20
+ except ImportError:
21
+ DQN_AVAILABLE = False
22
+
23
+ app = FastAPI(title="Traffic RL Simulation API")
24
+
25
+ # Allow CORS for frontend
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"],
29
+ allow_credentials=True,
30
+ allow_methods=["*"],
31
+ allow_headers=["*"],
32
+ )
33
+
34
+ # Serve static files from the frontend/dist directory
35
+ # This must be after the API routes for correct routing, but we can define it here
36
+ # if we mount it at the end. Actually, better to define it after all routes.
37
+
38
+ # Global instances
39
+ env = TrafficEnvironment(cfg)
40
+ agent = None
41
+
42
+ # Try to load DQN agent
43
+ if DQN_AVAILABLE:
44
+ agent = DQNAgent(cfg.STATE_SIZE, cfg.ACTION_SIZE, cfg.DQN_CONFIG)
45
+ model_path = ROOT_DIR / "models" / "dqn_best.pth"
46
+ if model_path.exists():
47
+ agent.load(str(model_path))
48
+ print("Backend: Loaded DQN Model successfully.")
49
+ else:
50
+ print("Backend: DQN Model file not found, using untrained agent.")
51
+ else:
52
+ # Fallback to QLearning
53
+ from agent import QLearningAgent
54
+ agent = QLearningAgent(cfg.STATE_SIZE, cfg.ACTION_SIZE, cfg.Q_LEARNING_CONFIG)
55
+ model_path = ROOT_DIR / "models" / "q_learning_best.pth"
56
+ if model_path.exists():
57
+ agent.load(str(model_path))
58
+ print("Backend: Loaded Q-Learning Model successfully.")
59
+ else:
60
+ print("Backend: Q-Learning Model file not found.")
61
+
62
+ class StateResponse(BaseModel):
63
+ queues: list[float]
64
+ phase: int
65
+ reward: float
66
+ vehicles_passed: int
67
+ step: int
68
+ total_reward: float
69
+ is_done: bool
70
+
71
+ current_state, _ = env.reset()
72
+ total_reward = 0.0
73
+
74
+ @app.post("/api/reset", response_model=StateResponse)
75
+ def reset_env():
76
+ global current_state, total_reward
77
+ current_state, _ = env.reset()
78
+ total_reward = 0.0
79
+ return {
80
+ "queues": env.queue_lengths.tolist(),
81
+ "phase": env.current_phase,
82
+ "reward": 0.0,
83
+ "vehicles_passed": env.vehicles_passed,
84
+ "step": env.current_step,
85
+ "total_reward": total_reward,
86
+ "is_done": False
87
+ }
88
+
89
+ @app.post("/api/step", response_model=StateResponse)
90
+ def step_env():
91
+ global current_state, total_reward
92
+
93
+ # Get action from the loaded agent (evaluation mode)
94
+ action = agent.select_action(current_state, training=False)
95
+
96
+ # Step the environment
97
+ next_state, reward, terminated, truncated, info = env.step(action)
98
+ done = terminated or truncated
99
+
100
+ current_state = next_state
101
+ total_reward += reward
102
+
103
+ response = {
104
+ "queues": env.queue_lengths.tolist(),
105
+ "phase": env.current_phase,
106
+ "reward": reward,
107
+ "vehicles_passed": env.vehicles_passed,
108
+ "step": env.current_step,
109
+ "total_reward": total_reward,
110
+ "is_done": done
111
+ }
112
+
113
+ if done:
114
+ # Reset for next call if done
115
+ current_state, _ = env.reset()
116
+ total_reward = 0.0
117
+
118
+ return response
119
+
120
+ # Mount the static files at the root
121
+ # Note: Ensure this is the last route defined
122
+ frontend_path = ROOT_DIR / "frontend" / "dist"
123
+ if frontend_path.exists():
124
+ app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="static")
125
+
126
+ if __name__ == "__main__":
127
+ import uvicorn
128
+ # Use port 7860 for Hugging Face Spaces compatibility
129
+ port = int(os.environ.get("PORT", 7860))
130
+ uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
131
+ # Restart to load fixed working 9D model
config.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration file for RL Traffic Signal Control project.
3
+
4
+ Contains all hyperparameters and settings for the environment,
5
+ agents, and training process.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ # ============================================================================
12
+ # PROJECT PATHS
13
+ # ============================================================================
14
+
15
+ ROOT_DIR = Path(__file__).parent
16
+ MODELS_DIR = ROOT_DIR / "models"
17
+ MODELS_DIR.mkdir(exist_ok=True)
18
+ RESULTS_DIR = ROOT_DIR / "results"
19
+ RESULTS_DIR.mkdir(exist_ok=True)
20
+
21
+ # ============================================================================
22
+ # ENVIRONMENT CONFIGURATION
23
+ # ============================================================================
24
+
25
+ NUM_LANES = 2 # Lanes per direction at each intersection
26
+ EPISODE_LENGTH = 3600 # Steps per episode (simulated 1 hour)
27
+ TIME_STEP = 1 # Simulation time step in seconds
28
+
29
+ # Traffic generation
30
+ TRAFFIC_DENSITY = 0.02 # Drastically reduced to hit ~1000-5000 throughput per episode
31
+ PEAK_HOURS = [(7, 9), (17, 19)] # Morning and evening rush hours
32
+ PEAK_MULTIPLIER = 1.5 # Traffic density multiplier during peak hours
33
+
34
+ # Signal timing constraints
35
+ MIN_GREEN_TIME = 10 # Minimum green light duration (seconds)
36
+ MAX_GREEN_TIME = 60 # Maximum green light duration (seconds)
37
+ YELLOW_TIME = 3 # Yellow light duration (seconds)
38
+ ALL_RED_TIME = 2 # All-red clearance time (seconds)
39
+
40
+ # ============================================================================
41
+ # AGENT CONFIGURATION
42
+ # ============================================================================
43
+
44
+ AGENT_TYPE = "dqn" # Options: "dqn", "q_learning"
45
+ STATE_SIZE = 9 # [N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L, phase]
46
+ ACTION_SIZE = 2 # 0=keep current phase, 1=switch phase
47
+
48
+ # Deep Q-Network (DQN) hyperparameters
49
+ DQN_CONFIG = {
50
+ "learning_rate": 0.0001, # Low LR for stability
51
+ "gamma": 0.99, # Discount factor
52
+ "epsilon_start": 1.0, # Initial exploration rate
53
+ "epsilon_end": 0.01, # Final exploration rate
54
+ "epsilon_decay": 0.998, # Slow decay for thorough exploration
55
+ "memory_size": 50000, # Replay buffer size
56
+ "batch_size": 256, # Larger batch = better GPU utilisation
57
+ "target_update": 10, # Target network update frequency (episodes)
58
+ "hidden_layers": [256, 256], # Slightly larger network for 9D state space
59
+ "train_frequency": 4, # Train every N env steps (reduces CPU-GPU overhead)
60
+ }
61
+
62
+ # Q-Learning (tabular) hyperparameters
63
+ Q_LEARNING_CONFIG = {
64
+ "learning_rate": 0.1, # Alpha
65
+ "gamma": 0.99, # Discount factor
66
+ "epsilon_start": 1.0, # Initial exploration rate
67
+ "epsilon_end": 0.01, # Final exploration rate
68
+ "epsilon_decay": 0.995, # Exploration decay rate
69
+ "num_bins": 10, # Bins per state dimension for discretization
70
+ }
71
+
72
+ # ============================================================================
73
+ # TRAINING CONFIGURATION
74
+ # ============================================================================
75
+
76
+ NUM_EPISODES = 1000 # Total training episodes
77
+ EVAL_FREQUENCY = 50 # Evaluate every N episodes
78
+ SAVE_FREQUENCY = 100 # Save checkpoint every N episodes
79
+ EARLY_STOPPING_PATIENCE = 100 # Stop if no improvement for N episodes
80
+ MIN_REWARD_THRESHOLD = -1000 # Minimum average reward threshold
81
+
82
+ # Logging
83
+ LOG_FREQUENCY = 10 # Log metrics every N episodes
84
+ USE_TENSORBOARD = False # Disabled by default (no extra deps)
85
+
86
+ # ============================================================================
87
+ # EVALUATION CONFIGURATION
88
+ # ============================================================================
89
+
90
+ NUM_EVAL_EPISODES = 10 # Episodes for evaluation
91
+ RENDER_EVAL = False # Render environment during evaluation
92
+
93
+ # ============================================================================
94
+ # VISUALIZATION
95
+ # ============================================================================
96
+
97
+ FIGURE_SIZE = (12, 6)
98
+ DPI = 100
99
+
100
+ METRICS = [
101
+ "episode_reward",
102
+ "average_waiting_time",
103
+ "average_queue_length",
104
+ "throughput",
105
+ ]
106
+
107
+ # ============================================================================
108
+ # RANDOM SEED
109
+ # ============================================================================
110
+
111
+ RANDOM_SEED = 42
112
+
113
+ # ============================================================================
114
+ # DEVICE
115
+ # ============================================================================
116
+
117
+ try:
118
+ import torch
119
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
120
+ print(f"[Config] Device: {DEVICE}")
121
+ except ImportError:
122
+ DEVICE = "cpu"
123
+ print("[Config] PyTorch not found, using CPU")
environment/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Environment package — exposes TrafficEnvironment and TrafficGenerator.
3
+ """
4
+
5
+ from .traffic_env import TrafficEnvironment
6
+ from .traffic_generator import TrafficGenerator
7
+
8
+ __all__ = ["TrafficEnvironment", "TrafficGenerator"]
environment/traffic_env.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Traffic Environment — Gymnasium-compatible RL environment for traffic signal control.
3
+
4
+ State space : [N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L, current_phase] (9 features, float32 ∈ [0,1])
5
+ Action space : Discrete(2) → 0 = keep phase, 1 = switch to next phase
6
+ Reward : −total_queue / 1000, clipped to [−1, 1]
7
+
8
+ Key design decisions (from PROJECT_EXPLANATION.md):
9
+ • Dynamic normalization (divide by current max) prevents state saturation.
10
+ • Directional phases (N, E, S, W) eliminate turning collisions.
11
+ • Extended green time (10 steps) when switching makes actions impactful.
12
+ • Reward clipping prevents gradient explosion during DQN training.
13
+ """
14
+
15
+ import numpy as np
16
+ import gymnasium as gym
17
+ from gymnasium import spaces
18
+
19
+ from .traffic_generator import TrafficGenerator
20
+
21
+
22
+ class TrafficEnvironment(gym.Env):
23
+ """
24
+ Single-intersection traffic signal control environment.
25
+
26
+ The agent controls a 4-phase signal and must minimise total vehicle
27
+ waiting time across all four approach lanes (N / E / S / W).
28
+ """
29
+
30
+ metadata = {"render_modes": ["human"], "render_fps": 30}
31
+
32
+ # Phase → green queue indices mapping (8 queues total)
33
+ # Phase 0: North (0=SR, 1=L), Phase 1: East (2=SR, 3=L)
34
+ # Phase 2: South (4=SR, 5=L), Phase 3: West (6=SR, 7=L)
35
+ _PHASE_GREEN: dict = {
36
+ 0: [0, 1],
37
+ 1: [2, 3],
38
+ 2: [4, 5],
39
+ 3: [6, 7],
40
+ }
41
+
42
+ def __init__(self, config=None):
43
+ """
44
+ Args:
45
+ config: Configuration module/object. Uses default config if None.
46
+ """
47
+ super().__init__()
48
+
49
+ if config is None:
50
+ import config as default_config
51
+ config = default_config
52
+
53
+ self.config = config
54
+
55
+ # Environment parameters
56
+ self.num_lanes = config.NUM_LANES
57
+ self.episode_length = config.EPISODE_LENGTH
58
+ self.min_green_time = 8 # Steps before a switch is allowed
59
+ self.extended_green_time = 10 # Extra processing steps after switch
60
+ self.yellow_time = config.YELLOW_TIME
61
+
62
+ # Traffic simulator
63
+ self.traffic_generator = TrafficGenerator(config)
64
+
65
+ # ── Observation space ──────────────────────────────────────────
66
+ # 8 queues + phase, all normalised ∈ [0, 1]
67
+ self.observation_space = spaces.Box(
68
+ low=0.0, high=1.0, shape=(9,), dtype=np.float32
69
+ )
70
+
71
+ # ── Action space ───────────────────────────────────────────────
72
+ # 0 = keep current phase | 1 = switch to next phase
73
+ self.action_space = spaces.Discrete(2)
74
+
75
+ # Internal state
76
+ self.current_step: int = 0
77
+ self.current_phase: int = 0
78
+ self.time_in_phase: int = 0
79
+ self.queue_lengths: np.ndarray = np.zeros(8, dtype=np.float32)
80
+ self.waiting_times: np.ndarray = np.zeros(8, dtype=np.float32)
81
+ self.vehicles_passed: int = 0
82
+ self.last_action: int = 0
83
+
84
+ self.render_mode = None
85
+
86
+ # ------------------------------------------------------------------
87
+ # Gymnasium API
88
+ # ------------------------------------------------------------------
89
+
90
+ def reset(self, seed=None, options=None):
91
+ """Reset environment to initial state and return (observation, info)."""
92
+ super().reset(seed=seed)
93
+
94
+ self.current_step = 0
95
+ self.current_phase = 0
96
+ self.time_in_phase = 0
97
+ self.queue_lengths = np.zeros(8, dtype=np.float32)
98
+ self.waiting_times = np.zeros(8, dtype=np.float32)
99
+ self.vehicles_passed = 0
100
+ self.last_action = 0
101
+
102
+ self.traffic_generator.reset()
103
+
104
+ observation = self._get_observation()
105
+ info = self._get_info()
106
+
107
+ return observation, info
108
+
109
+ def step(self, action: int):
110
+ """
111
+ Execute one decision step.
112
+
113
+ Args:
114
+ action: 0 = keep current phase, 1 = switch to next phase.
115
+
116
+ Returns:
117
+ (observation, reward, terminated, truncated, info)
118
+ """
119
+ if not self.action_space.contains(action):
120
+ raise ValueError(f"Invalid action {action!r}. Must be 0 or 1.")
121
+
122
+ is_switching = bool(action == 1)
123
+
124
+ # ── Phase switch ───────────────────────────────────────────────
125
+ if is_switching and self.time_in_phase >= self.min_green_time:
126
+ self.current_phase = (self.current_phase + 1) % 4
127
+ self.time_in_phase = 0
128
+
129
+ # Extended green: process multiple clearing steps for visible impact
130
+ for _ in range(self.extended_green_time):
131
+ cleared = self._process_phase()
132
+ self.vehicles_passed += int(cleared)
133
+
134
+ self.time_in_phase += 1
135
+ self.current_step += 1
136
+
137
+ # ── Vehicle arrivals ───────────────────────────────────────────
138
+ new_vehicles = self.traffic_generator.generate(self.current_step)
139
+ self.queue_lengths = self.queue_lengths + new_vehicles
140
+
141
+ # ── Normal phase processing ────────────────────────────────────
142
+ vehicles_passing = self._process_phase()
143
+ self.vehicles_passed += int(vehicles_passing)
144
+
145
+ # ── Waiting time accumulation ──────────────────────────────────
146
+ self.waiting_times = self.waiting_times + self.queue_lengths
147
+
148
+ # ── Reward ────────────────────────────────────────────────────
149
+ reward = float(self._calculate_reward())
150
+
151
+ self.last_action = action
152
+
153
+ terminated = bool(self.current_step >= self.episode_length)
154
+ truncated = False
155
+
156
+ observation = self._get_observation()
157
+ info = self._get_info()
158
+ info["waiting_time"] = float(np.sum(self.waiting_times))
159
+ info["queue_length"] = float(np.sum(self.queue_lengths))
160
+
161
+ return observation, reward, terminated, truncated, info
162
+
163
+ def render(self):
164
+ """Console render (human mode)."""
165
+ if self.render_mode == "human":
166
+ print(
167
+ f"Step: {self.current_step:4d} | Phase: {self.current_phase} | "
168
+ f"Queues: {self.queue_lengths} | Passed: {self.vehicles_passed}"
169
+ )
170
+
171
+ def close(self):
172
+ pass
173
+
174
+ # ------------------------------------------------------------------
175
+ # Internal helpers
176
+ # ------------------------------------------------------------------
177
+
178
+ def _get_observation(self) -> np.ndarray:
179
+ """
180
+ Build the 9-dimensional state vector.
181
+
182
+ Queue features are normalised by the current maximum queue value
183
+ (dynamic normalisation) to preserve relative lane differences and
184
+ prevent saturation when absolute queue counts are large.
185
+ """
186
+ queue_state = self.queue_lengths.copy().astype(np.float32)
187
+
188
+ # Absolute normalisation (cap at 20 vehicles to keep ∈ [0, 1])
189
+ queue_state = np.clip(queue_state / 20.0, 0.0, 1.0)
190
+
191
+ phase_state = np.array(
192
+ [float(self.current_phase) / 3.0], dtype=np.float32
193
+ )
194
+
195
+ observation = np.concatenate([queue_state, phase_state])
196
+
197
+ # Validate
198
+ assert observation.shape == (9,), f"Bad obs shape: {observation.shape}"
199
+ assert observation.dtype == np.float32
200
+ assert not np.any(np.isnan(observation)), "NaN in observation"
201
+ assert not np.any(np.isinf(observation)), "Inf in observation"
202
+
203
+ return observation
204
+
205
+ def _get_info(self) -> dict:
206
+ return {
207
+ "current_step": self.current_step,
208
+ "current_phase": self.current_phase,
209
+ "total_queue_length": float(np.sum(self.queue_lengths)),
210
+ "average_waiting_time": float(np.mean(self.waiting_times)),
211
+ "vehicles_passed": self.vehicles_passed,
212
+ }
213
+
214
+ def _process_phase(self) -> float:
215
+ """
216
+ Clear vehicles from green-light lanes.
217
+
218
+ Returns:
219
+ vehicles_passing: Number of vehicles that cleared this step.
220
+ """
221
+ green_dirs = self._PHASE_GREEN.get(self.current_phase, [])
222
+ vehicles_passing = 0.0
223
+
224
+ for d in green_dirs:
225
+ if self.queue_lengths[d] > 0:
226
+ passing = min(
227
+ self.queue_lengths[d],
228
+ float(np.random.randint(1, 3)),
229
+ )
230
+ self.queue_lengths[d] -= passing
231
+ vehicles_passing += passing
232
+
233
+ return vehicles_passing
234
+
235
+ def _calculate_reward(self) -> float:
236
+ """
237
+ Compute reward signal.
238
+
239
+ reward = −total_queue / 1000 (clipped to [−1, 1])
240
+
241
+ Dividing by 1000 keeps the magnitude in a range suitable for
242
+ stable neural-network training; clipping prevents extreme gradients.
243
+ """
244
+ total_queue = float(np.sum(self.queue_lengths))
245
+ reward = -total_queue / 20.0
246
+ return float(np.clip(reward, -1.0, 1.0))
environment/traffic_generator.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Traffic Generator — simulates vehicle arrivals at the intersection.
3
+
4
+ Implements complex, realistic traffic patterns:
5
+ - Extreme lane imbalance (North gets ~70% of traffic)
6
+ - Dynamic peak/low phases (alternating every 100 steps)
7
+ - Random traffic bursts (15% probability, 4× multiplier)
8
+ - Variable vehicle counts (2–8 per arrival event)
9
+ """
10
+
11
+ import numpy as np
12
+
13
+
14
+ class TrafficGenerator:
15
+ """
16
+ Generates stochastic traffic patterns for the simulation.
17
+
18
+ The deliberately uneven distribution ensures that a fixed-timing signal
19
+ cannot match the performance of an adaptive RL agent.
20
+ """
21
+
22
+ def __init__(self, config):
23
+ """
24
+ Args:
25
+ config: Module or object exposing traffic-related constants.
26
+ """
27
+ self.config = config
28
+
29
+ # Base density — drastically lowered for ~2000-4000 throughput
30
+ self.traffic_density = config.TRAFFIC_DENSITY
31
+
32
+ self.peak_hours = config.PEAK_HOURS
33
+ self.peak_multiplier = config.PEAK_MULTIPLIER
34
+
35
+ # Random number generator
36
+ self.rng = np.random.default_rng()
37
+
38
+ # Burst parameters
39
+ self.burst_probability = 0.15 # 15% chance of burst per step
40
+ self.burst_active = False
41
+ self.burst_duration = 0
42
+ self.burst_direction = 0
43
+
44
+ # Lane imbalance: North gets ~45% of traffic — uneven but manageable
45
+ # Previously [2.8, 0.4, 0.5, 0.3] caused North density > 1.0 every step
46
+ weights = np.array([1.8, 0.7, 0.7, 0.6])
47
+ self.lane_weights = weights / weights.sum() * 4 # Normalised
48
+
49
+ # Dynamic phase: alternates peak/low every 100 steps
50
+ self.phase_length = 100
51
+ self.current_phase_step = 0
52
+ self.is_peak_phase = True
53
+
54
+ # ------------------------------------------------------------------
55
+ # Public API
56
+ # ------------------------------------------------------------------
57
+
58
+ def reset(self):
59
+ """Reset generator state at the start of each episode."""
60
+ self.burst_active = False
61
+ self.burst_duration = 0
62
+ self.burst_direction = 0
63
+ self.current_phase_step = 0
64
+ self.is_peak_phase = True
65
+
66
+ def generate(self, current_step: int) -> np.ndarray:
67
+ """
68
+ Generate new vehicles for the current time step.
69
+
70
+ Args:
71
+ current_step: Current simulation step counter.
72
+
73
+ Returns:
74
+ new_vehicles: Array [N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L] of vehicle counts.
75
+ """
76
+ # --- Dynamic phase (peak / low, toggles every 100 steps) ---
77
+ self.current_phase_step += 1
78
+ if self.current_phase_step >= self.phase_length:
79
+ self.current_phase_step = 0
80
+ self.is_peak_phase = not self.is_peak_phase
81
+
82
+ density = (
83
+ self.traffic_density * 1.5
84
+ if self.is_peak_phase
85
+ else self.traffic_density * 0.5
86
+ )
87
+
88
+ # Traditional peak-hour multiplier
89
+ hour = (current_step // 3600) % 24
90
+ for start_h, end_h in self.peak_hours:
91
+ if start_h <= hour < end_h:
92
+ density *= self.peak_multiplier
93
+ break
94
+
95
+ # --- Traffic burst ---
96
+ if not self.burst_active:
97
+ if self.rng.random() < self.burst_probability:
98
+ self.burst_active = True
99
+ self.burst_duration = int(self.rng.integers(15, 40))
100
+ self.burst_direction = int(self.rng.integers(0, 4))
101
+ else:
102
+ self.burst_duration -= 1
103
+ if self.burst_duration <= 0:
104
+ self.burst_active = False
105
+
106
+ # --- Vehicle arrivals per direction (8 queues) ---
107
+ new_vehicles = np.zeros(8, dtype=np.float32)
108
+ for direction in range(4):
109
+ lane_density = density * self.lane_weights[direction]
110
+
111
+ if self.burst_active and direction == self.burst_direction:
112
+ lane_density *= 4.0 # Burst spike
113
+
114
+ if self.rng.random() < lane_density:
115
+ # 1-3 vehicles arrive
116
+ total_arriving = self.rng.integers(1, 4)
117
+ for _ in range(total_arriving):
118
+ # 20% chance of turning left
119
+ is_left_turn = self.rng.random() < 0.2
120
+ if is_left_turn:
121
+ new_vehicles[direction * 2 + 1] += 1.0 # Left turn queue
122
+ else:
123
+ new_vehicles[direction * 2] += 1.0 # Straight/Right queue
124
+
125
+ return new_vehicles
126
+
127
+ def set_density(self, density: float):
128
+ """Override base traffic density (0.0–1.0)."""
129
+ self.traffic_density = float(np.clip(density, 0.0, 1.0))
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>frontend</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,962 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "frontend",
9
+ "version": "0.0.0",
10
+ "dependencies": {
11
+ "lucide-react": "^1.11.0",
12
+ "react": "^19.2.5",
13
+ "react-dom": "^19.2.5"
14
+ },
15
+ "devDependencies": {
16
+ "@vitejs/plugin-react": "^6.0.1",
17
+ "typescript": "~6.0.2",
18
+ "vite": "^8.0.10"
19
+ }
20
+ },
21
+ "node_modules/@emnapi/core": {
22
+ "version": "1.10.0",
23
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
24
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
25
+ "dev": true,
26
+ "license": "MIT",
27
+ "optional": true,
28
+ "dependencies": {
29
+ "@emnapi/wasi-threads": "1.2.1",
30
+ "tslib": "^2.4.0"
31
+ }
32
+ },
33
+ "node_modules/@emnapi/runtime": {
34
+ "version": "1.10.0",
35
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
36
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
37
+ "dev": true,
38
+ "license": "MIT",
39
+ "optional": true,
40
+ "dependencies": {
41
+ "tslib": "^2.4.0"
42
+ }
43
+ },
44
+ "node_modules/@emnapi/wasi-threads": {
45
+ "version": "1.2.1",
46
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
47
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
48
+ "dev": true,
49
+ "license": "MIT",
50
+ "optional": true,
51
+ "dependencies": {
52
+ "tslib": "^2.4.0"
53
+ }
54
+ },
55
+ "node_modules/@napi-rs/wasm-runtime": {
56
+ "version": "1.1.4",
57
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
58
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
59
+ "dev": true,
60
+ "license": "MIT",
61
+ "optional": true,
62
+ "dependencies": {
63
+ "@tybys/wasm-util": "^0.10.1"
64
+ },
65
+ "funding": {
66
+ "type": "github",
67
+ "url": "https://github.com/sponsors/Brooooooklyn"
68
+ },
69
+ "peerDependencies": {
70
+ "@emnapi/core": "^1.7.1",
71
+ "@emnapi/runtime": "^1.7.1"
72
+ }
73
+ },
74
+ "node_modules/@oxc-project/types": {
75
+ "version": "0.127.0",
76
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
77
+ "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
78
+ "dev": true,
79
+ "license": "MIT",
80
+ "funding": {
81
+ "url": "https://github.com/sponsors/Boshen"
82
+ }
83
+ },
84
+ "node_modules/@rolldown/binding-android-arm64": {
85
+ "version": "1.0.0-rc.17",
86
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
87
+ "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
88
+ "cpu": [
89
+ "arm64"
90
+ ],
91
+ "dev": true,
92
+ "license": "MIT",
93
+ "optional": true,
94
+ "os": [
95
+ "android"
96
+ ],
97
+ "engines": {
98
+ "node": "^20.19.0 || >=22.12.0"
99
+ }
100
+ },
101
+ "node_modules/@rolldown/binding-darwin-arm64": {
102
+ "version": "1.0.0-rc.17",
103
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
104
+ "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
105
+ "cpu": [
106
+ "arm64"
107
+ ],
108
+ "dev": true,
109
+ "license": "MIT",
110
+ "optional": true,
111
+ "os": [
112
+ "darwin"
113
+ ],
114
+ "engines": {
115
+ "node": "^20.19.0 || >=22.12.0"
116
+ }
117
+ },
118
+ "node_modules/@rolldown/binding-darwin-x64": {
119
+ "version": "1.0.0-rc.17",
120
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
121
+ "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
122
+ "cpu": [
123
+ "x64"
124
+ ],
125
+ "dev": true,
126
+ "license": "MIT",
127
+ "optional": true,
128
+ "os": [
129
+ "darwin"
130
+ ],
131
+ "engines": {
132
+ "node": "^20.19.0 || >=22.12.0"
133
+ }
134
+ },
135
+ "node_modules/@rolldown/binding-freebsd-x64": {
136
+ "version": "1.0.0-rc.17",
137
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
138
+ "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
139
+ "cpu": [
140
+ "x64"
141
+ ],
142
+ "dev": true,
143
+ "license": "MIT",
144
+ "optional": true,
145
+ "os": [
146
+ "freebsd"
147
+ ],
148
+ "engines": {
149
+ "node": "^20.19.0 || >=22.12.0"
150
+ }
151
+ },
152
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
153
+ "version": "1.0.0-rc.17",
154
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
155
+ "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
156
+ "cpu": [
157
+ "arm"
158
+ ],
159
+ "dev": true,
160
+ "license": "MIT",
161
+ "optional": true,
162
+ "os": [
163
+ "linux"
164
+ ],
165
+ "engines": {
166
+ "node": "^20.19.0 || >=22.12.0"
167
+ }
168
+ },
169
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
170
+ "version": "1.0.0-rc.17",
171
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
172
+ "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
173
+ "cpu": [
174
+ "arm64"
175
+ ],
176
+ "dev": true,
177
+ "license": "MIT",
178
+ "optional": true,
179
+ "os": [
180
+ "linux"
181
+ ],
182
+ "engines": {
183
+ "node": "^20.19.0 || >=22.12.0"
184
+ }
185
+ },
186
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
187
+ "version": "1.0.0-rc.17",
188
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
189
+ "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
190
+ "cpu": [
191
+ "arm64"
192
+ ],
193
+ "dev": true,
194
+ "license": "MIT",
195
+ "optional": true,
196
+ "os": [
197
+ "linux"
198
+ ],
199
+ "engines": {
200
+ "node": "^20.19.0 || >=22.12.0"
201
+ }
202
+ },
203
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
204
+ "version": "1.0.0-rc.17",
205
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
206
+ "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
207
+ "cpu": [
208
+ "ppc64"
209
+ ],
210
+ "dev": true,
211
+ "license": "MIT",
212
+ "optional": true,
213
+ "os": [
214
+ "linux"
215
+ ],
216
+ "engines": {
217
+ "node": "^20.19.0 || >=22.12.0"
218
+ }
219
+ },
220
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
221
+ "version": "1.0.0-rc.17",
222
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
223
+ "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
224
+ "cpu": [
225
+ "s390x"
226
+ ],
227
+ "dev": true,
228
+ "license": "MIT",
229
+ "optional": true,
230
+ "os": [
231
+ "linux"
232
+ ],
233
+ "engines": {
234
+ "node": "^20.19.0 || >=22.12.0"
235
+ }
236
+ },
237
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
238
+ "version": "1.0.0-rc.17",
239
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
240
+ "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
241
+ "cpu": [
242
+ "x64"
243
+ ],
244
+ "dev": true,
245
+ "license": "MIT",
246
+ "optional": true,
247
+ "os": [
248
+ "linux"
249
+ ],
250
+ "engines": {
251
+ "node": "^20.19.0 || >=22.12.0"
252
+ }
253
+ },
254
+ "node_modules/@rolldown/binding-linux-x64-musl": {
255
+ "version": "1.0.0-rc.17",
256
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
257
+ "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
258
+ "cpu": [
259
+ "x64"
260
+ ],
261
+ "dev": true,
262
+ "license": "MIT",
263
+ "optional": true,
264
+ "os": [
265
+ "linux"
266
+ ],
267
+ "engines": {
268
+ "node": "^20.19.0 || >=22.12.0"
269
+ }
270
+ },
271
+ "node_modules/@rolldown/binding-openharmony-arm64": {
272
+ "version": "1.0.0-rc.17",
273
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
274
+ "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
275
+ "cpu": [
276
+ "arm64"
277
+ ],
278
+ "dev": true,
279
+ "license": "MIT",
280
+ "optional": true,
281
+ "os": [
282
+ "openharmony"
283
+ ],
284
+ "engines": {
285
+ "node": "^20.19.0 || >=22.12.0"
286
+ }
287
+ },
288
+ "node_modules/@rolldown/binding-wasm32-wasi": {
289
+ "version": "1.0.0-rc.17",
290
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
291
+ "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
292
+ "cpu": [
293
+ "wasm32"
294
+ ],
295
+ "dev": true,
296
+ "license": "MIT",
297
+ "optional": true,
298
+ "dependencies": {
299
+ "@emnapi/core": "1.10.0",
300
+ "@emnapi/runtime": "1.10.0",
301
+ "@napi-rs/wasm-runtime": "^1.1.4"
302
+ },
303
+ "engines": {
304
+ "node": "^20.19.0 || >=22.12.0"
305
+ }
306
+ },
307
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
308
+ "version": "1.0.0-rc.17",
309
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
310
+ "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
311
+ "cpu": [
312
+ "arm64"
313
+ ],
314
+ "dev": true,
315
+ "license": "MIT",
316
+ "optional": true,
317
+ "os": [
318
+ "win32"
319
+ ],
320
+ "engines": {
321
+ "node": "^20.19.0 || >=22.12.0"
322
+ }
323
+ },
324
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
325
+ "version": "1.0.0-rc.17",
326
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
327
+ "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
328
+ "cpu": [
329
+ "x64"
330
+ ],
331
+ "dev": true,
332
+ "license": "MIT",
333
+ "optional": true,
334
+ "os": [
335
+ "win32"
336
+ ],
337
+ "engines": {
338
+ "node": "^20.19.0 || >=22.12.0"
339
+ }
340
+ },
341
+ "node_modules/@rolldown/pluginutils": {
342
+ "version": "1.0.0-rc.17",
343
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
344
+ "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
345
+ "dev": true,
346
+ "license": "MIT"
347
+ },
348
+ "node_modules/@tybys/wasm-util": {
349
+ "version": "0.10.1",
350
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
351
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
352
+ "dev": true,
353
+ "license": "MIT",
354
+ "optional": true,
355
+ "dependencies": {
356
+ "tslib": "^2.4.0"
357
+ }
358
+ },
359
+ "node_modules/@vitejs/plugin-react": {
360
+ "version": "6.0.1",
361
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
362
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
363
+ "dev": true,
364
+ "license": "MIT",
365
+ "dependencies": {
366
+ "@rolldown/pluginutils": "1.0.0-rc.7"
367
+ },
368
+ "engines": {
369
+ "node": "^20.19.0 || >=22.12.0"
370
+ },
371
+ "peerDependencies": {
372
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
373
+ "babel-plugin-react-compiler": "^1.0.0",
374
+ "vite": "^8.0.0"
375
+ },
376
+ "peerDependenciesMeta": {
377
+ "@rolldown/plugin-babel": {
378
+ "optional": true
379
+ },
380
+ "babel-plugin-react-compiler": {
381
+ "optional": true
382
+ }
383
+ }
384
+ },
385
+ "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": {
386
+ "version": "1.0.0-rc.7",
387
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
388
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
389
+ "dev": true,
390
+ "license": "MIT"
391
+ },
392
+ "node_modules/detect-libc": {
393
+ "version": "2.1.2",
394
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
395
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
396
+ "dev": true,
397
+ "license": "Apache-2.0",
398
+ "engines": {
399
+ "node": ">=8"
400
+ }
401
+ },
402
+ "node_modules/fdir": {
403
+ "version": "6.5.0",
404
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
405
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
406
+ "dev": true,
407
+ "license": "MIT",
408
+ "engines": {
409
+ "node": ">=12.0.0"
410
+ },
411
+ "peerDependencies": {
412
+ "picomatch": "^3 || ^4"
413
+ },
414
+ "peerDependenciesMeta": {
415
+ "picomatch": {
416
+ "optional": true
417
+ }
418
+ }
419
+ },
420
+ "node_modules/fsevents": {
421
+ "version": "2.3.3",
422
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
423
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
424
+ "dev": true,
425
+ "hasInstallScript": true,
426
+ "license": "MIT",
427
+ "optional": true,
428
+ "os": [
429
+ "darwin"
430
+ ],
431
+ "engines": {
432
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
433
+ }
434
+ },
435
+ "node_modules/lightningcss": {
436
+ "version": "1.32.0",
437
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
438
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
439
+ "dev": true,
440
+ "license": "MPL-2.0",
441
+ "dependencies": {
442
+ "detect-libc": "^2.0.3"
443
+ },
444
+ "engines": {
445
+ "node": ">= 12.0.0"
446
+ },
447
+ "funding": {
448
+ "type": "opencollective",
449
+ "url": "https://opencollective.com/parcel"
450
+ },
451
+ "optionalDependencies": {
452
+ "lightningcss-android-arm64": "1.32.0",
453
+ "lightningcss-darwin-arm64": "1.32.0",
454
+ "lightningcss-darwin-x64": "1.32.0",
455
+ "lightningcss-freebsd-x64": "1.32.0",
456
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
457
+ "lightningcss-linux-arm64-gnu": "1.32.0",
458
+ "lightningcss-linux-arm64-musl": "1.32.0",
459
+ "lightningcss-linux-x64-gnu": "1.32.0",
460
+ "lightningcss-linux-x64-musl": "1.32.0",
461
+ "lightningcss-win32-arm64-msvc": "1.32.0",
462
+ "lightningcss-win32-x64-msvc": "1.32.0"
463
+ }
464
+ },
465
+ "node_modules/lightningcss-android-arm64": {
466
+ "version": "1.32.0",
467
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
468
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
469
+ "cpu": [
470
+ "arm64"
471
+ ],
472
+ "dev": true,
473
+ "license": "MPL-2.0",
474
+ "optional": true,
475
+ "os": [
476
+ "android"
477
+ ],
478
+ "engines": {
479
+ "node": ">= 12.0.0"
480
+ },
481
+ "funding": {
482
+ "type": "opencollective",
483
+ "url": "https://opencollective.com/parcel"
484
+ }
485
+ },
486
+ "node_modules/lightningcss-darwin-arm64": {
487
+ "version": "1.32.0",
488
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
489
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
490
+ "cpu": [
491
+ "arm64"
492
+ ],
493
+ "dev": true,
494
+ "license": "MPL-2.0",
495
+ "optional": true,
496
+ "os": [
497
+ "darwin"
498
+ ],
499
+ "engines": {
500
+ "node": ">= 12.0.0"
501
+ },
502
+ "funding": {
503
+ "type": "opencollective",
504
+ "url": "https://opencollective.com/parcel"
505
+ }
506
+ },
507
+ "node_modules/lightningcss-darwin-x64": {
508
+ "version": "1.32.0",
509
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
510
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
511
+ "cpu": [
512
+ "x64"
513
+ ],
514
+ "dev": true,
515
+ "license": "MPL-2.0",
516
+ "optional": true,
517
+ "os": [
518
+ "darwin"
519
+ ],
520
+ "engines": {
521
+ "node": ">= 12.0.0"
522
+ },
523
+ "funding": {
524
+ "type": "opencollective",
525
+ "url": "https://opencollective.com/parcel"
526
+ }
527
+ },
528
+ "node_modules/lightningcss-freebsd-x64": {
529
+ "version": "1.32.0",
530
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
531
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
532
+ "cpu": [
533
+ "x64"
534
+ ],
535
+ "dev": true,
536
+ "license": "MPL-2.0",
537
+ "optional": true,
538
+ "os": [
539
+ "freebsd"
540
+ ],
541
+ "engines": {
542
+ "node": ">= 12.0.0"
543
+ },
544
+ "funding": {
545
+ "type": "opencollective",
546
+ "url": "https://opencollective.com/parcel"
547
+ }
548
+ },
549
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
550
+ "version": "1.32.0",
551
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
552
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
553
+ "cpu": [
554
+ "arm"
555
+ ],
556
+ "dev": true,
557
+ "license": "MPL-2.0",
558
+ "optional": true,
559
+ "os": [
560
+ "linux"
561
+ ],
562
+ "engines": {
563
+ "node": ">= 12.0.0"
564
+ },
565
+ "funding": {
566
+ "type": "opencollective",
567
+ "url": "https://opencollective.com/parcel"
568
+ }
569
+ },
570
+ "node_modules/lightningcss-linux-arm64-gnu": {
571
+ "version": "1.32.0",
572
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
573
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
574
+ "cpu": [
575
+ "arm64"
576
+ ],
577
+ "dev": true,
578
+ "license": "MPL-2.0",
579
+ "optional": true,
580
+ "os": [
581
+ "linux"
582
+ ],
583
+ "engines": {
584
+ "node": ">= 12.0.0"
585
+ },
586
+ "funding": {
587
+ "type": "opencollective",
588
+ "url": "https://opencollective.com/parcel"
589
+ }
590
+ },
591
+ "node_modules/lightningcss-linux-arm64-musl": {
592
+ "version": "1.32.0",
593
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
594
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
595
+ "cpu": [
596
+ "arm64"
597
+ ],
598
+ "dev": true,
599
+ "license": "MPL-2.0",
600
+ "optional": true,
601
+ "os": [
602
+ "linux"
603
+ ],
604
+ "engines": {
605
+ "node": ">= 12.0.0"
606
+ },
607
+ "funding": {
608
+ "type": "opencollective",
609
+ "url": "https://opencollective.com/parcel"
610
+ }
611
+ },
612
+ "node_modules/lightningcss-linux-x64-gnu": {
613
+ "version": "1.32.0",
614
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
615
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
616
+ "cpu": [
617
+ "x64"
618
+ ],
619
+ "dev": true,
620
+ "license": "MPL-2.0",
621
+ "optional": true,
622
+ "os": [
623
+ "linux"
624
+ ],
625
+ "engines": {
626
+ "node": ">= 12.0.0"
627
+ },
628
+ "funding": {
629
+ "type": "opencollective",
630
+ "url": "https://opencollective.com/parcel"
631
+ }
632
+ },
633
+ "node_modules/lightningcss-linux-x64-musl": {
634
+ "version": "1.32.0",
635
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
636
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
637
+ "cpu": [
638
+ "x64"
639
+ ],
640
+ "dev": true,
641
+ "license": "MPL-2.0",
642
+ "optional": true,
643
+ "os": [
644
+ "linux"
645
+ ],
646
+ "engines": {
647
+ "node": ">= 12.0.0"
648
+ },
649
+ "funding": {
650
+ "type": "opencollective",
651
+ "url": "https://opencollective.com/parcel"
652
+ }
653
+ },
654
+ "node_modules/lightningcss-win32-arm64-msvc": {
655
+ "version": "1.32.0",
656
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
657
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
658
+ "cpu": [
659
+ "arm64"
660
+ ],
661
+ "dev": true,
662
+ "license": "MPL-2.0",
663
+ "optional": true,
664
+ "os": [
665
+ "win32"
666
+ ],
667
+ "engines": {
668
+ "node": ">= 12.0.0"
669
+ },
670
+ "funding": {
671
+ "type": "opencollective",
672
+ "url": "https://opencollective.com/parcel"
673
+ }
674
+ },
675
+ "node_modules/lightningcss-win32-x64-msvc": {
676
+ "version": "1.32.0",
677
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
678
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
679
+ "cpu": [
680
+ "x64"
681
+ ],
682
+ "dev": true,
683
+ "license": "MPL-2.0",
684
+ "optional": true,
685
+ "os": [
686
+ "win32"
687
+ ],
688
+ "engines": {
689
+ "node": ">= 12.0.0"
690
+ },
691
+ "funding": {
692
+ "type": "opencollective",
693
+ "url": "https://opencollective.com/parcel"
694
+ }
695
+ },
696
+ "node_modules/lucide-react": {
697
+ "version": "1.11.0",
698
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
699
+ "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
700
+ "license": "ISC",
701
+ "peerDependencies": {
702
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
703
+ }
704
+ },
705
+ "node_modules/nanoid": {
706
+ "version": "3.3.11",
707
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
708
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
709
+ "dev": true,
710
+ "funding": [
711
+ {
712
+ "type": "github",
713
+ "url": "https://github.com/sponsors/ai"
714
+ }
715
+ ],
716
+ "license": "MIT",
717
+ "bin": {
718
+ "nanoid": "bin/nanoid.cjs"
719
+ },
720
+ "engines": {
721
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
722
+ }
723
+ },
724
+ "node_modules/picocolors": {
725
+ "version": "1.1.1",
726
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
727
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
728
+ "dev": true,
729
+ "license": "ISC"
730
+ },
731
+ "node_modules/picomatch": {
732
+ "version": "4.0.4",
733
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
734
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
735
+ "dev": true,
736
+ "license": "MIT",
737
+ "engines": {
738
+ "node": ">=12"
739
+ },
740
+ "funding": {
741
+ "url": "https://github.com/sponsors/jonschlinkert"
742
+ }
743
+ },
744
+ "node_modules/postcss": {
745
+ "version": "8.5.12",
746
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
747
+ "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
748
+ "dev": true,
749
+ "funding": [
750
+ {
751
+ "type": "opencollective",
752
+ "url": "https://opencollective.com/postcss/"
753
+ },
754
+ {
755
+ "type": "tidelift",
756
+ "url": "https://tidelift.com/funding/github/npm/postcss"
757
+ },
758
+ {
759
+ "type": "github",
760
+ "url": "https://github.com/sponsors/ai"
761
+ }
762
+ ],
763
+ "license": "MIT",
764
+ "dependencies": {
765
+ "nanoid": "^3.3.11",
766
+ "picocolors": "^1.1.1",
767
+ "source-map-js": "^1.2.1"
768
+ },
769
+ "engines": {
770
+ "node": "^10 || ^12 || >=14"
771
+ }
772
+ },
773
+ "node_modules/react": {
774
+ "version": "19.2.5",
775
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
776
+ "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
777
+ "license": "MIT",
778
+ "engines": {
779
+ "node": ">=0.10.0"
780
+ }
781
+ },
782
+ "node_modules/react-dom": {
783
+ "version": "19.2.5",
784
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
785
+ "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
786
+ "license": "MIT",
787
+ "dependencies": {
788
+ "scheduler": "^0.27.0"
789
+ },
790
+ "peerDependencies": {
791
+ "react": "^19.2.5"
792
+ }
793
+ },
794
+ "node_modules/rolldown": {
795
+ "version": "1.0.0-rc.17",
796
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
797
+ "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
798
+ "dev": true,
799
+ "license": "MIT",
800
+ "dependencies": {
801
+ "@oxc-project/types": "=0.127.0",
802
+ "@rolldown/pluginutils": "1.0.0-rc.17"
803
+ },
804
+ "bin": {
805
+ "rolldown": "bin/cli.mjs"
806
+ },
807
+ "engines": {
808
+ "node": "^20.19.0 || >=22.12.0"
809
+ },
810
+ "optionalDependencies": {
811
+ "@rolldown/binding-android-arm64": "1.0.0-rc.17",
812
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
813
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
814
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
815
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
816
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
817
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
818
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
819
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
820
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
821
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
822
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
823
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
824
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
825
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
826
+ }
827
+ },
828
+ "node_modules/scheduler": {
829
+ "version": "0.27.0",
830
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
831
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
832
+ "license": "MIT"
833
+ },
834
+ "node_modules/source-map-js": {
835
+ "version": "1.2.1",
836
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
837
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
838
+ "dev": true,
839
+ "license": "BSD-3-Clause",
840
+ "engines": {
841
+ "node": ">=0.10.0"
842
+ }
843
+ },
844
+ "node_modules/tinyglobby": {
845
+ "version": "0.2.16",
846
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
847
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
848
+ "dev": true,
849
+ "license": "MIT",
850
+ "dependencies": {
851
+ "fdir": "^6.5.0",
852
+ "picomatch": "^4.0.4"
853
+ },
854
+ "engines": {
855
+ "node": ">=12.0.0"
856
+ },
857
+ "funding": {
858
+ "url": "https://github.com/sponsors/SuperchupuDev"
859
+ }
860
+ },
861
+ "node_modules/tslib": {
862
+ "version": "2.8.1",
863
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
864
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
865
+ "dev": true,
866
+ "license": "0BSD",
867
+ "optional": true
868
+ },
869
+ "node_modules/typescript": {
870
+ "version": "6.0.3",
871
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
872
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
873
+ "dev": true,
874
+ "license": "Apache-2.0",
875
+ "bin": {
876
+ "tsc": "bin/tsc",
877
+ "tsserver": "bin/tsserver"
878
+ },
879
+ "engines": {
880
+ "node": ">=14.17"
881
+ }
882
+ },
883
+ "node_modules/vite": {
884
+ "version": "8.0.10",
885
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
886
+ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
887
+ "dev": true,
888
+ "license": "MIT",
889
+ "dependencies": {
890
+ "lightningcss": "^1.32.0",
891
+ "picomatch": "^4.0.4",
892
+ "postcss": "^8.5.10",
893
+ "rolldown": "1.0.0-rc.17",
894
+ "tinyglobby": "^0.2.16"
895
+ },
896
+ "bin": {
897
+ "vite": "bin/vite.js"
898
+ },
899
+ "engines": {
900
+ "node": "^20.19.0 || >=22.12.0"
901
+ },
902
+ "funding": {
903
+ "url": "https://github.com/vitejs/vite?sponsor=1"
904
+ },
905
+ "optionalDependencies": {
906
+ "fsevents": "~2.3.3"
907
+ },
908
+ "peerDependencies": {
909
+ "@types/node": "^20.19.0 || >=22.12.0",
910
+ "@vitejs/devtools": "^0.1.0",
911
+ "esbuild": "^0.27.0 || ^0.28.0",
912
+ "jiti": ">=1.21.0",
913
+ "less": "^4.0.0",
914
+ "sass": "^1.70.0",
915
+ "sass-embedded": "^1.70.0",
916
+ "stylus": ">=0.54.8",
917
+ "sugarss": "^5.0.0",
918
+ "terser": "^5.16.0",
919
+ "tsx": "^4.8.1",
920
+ "yaml": "^2.4.2"
921
+ },
922
+ "peerDependenciesMeta": {
923
+ "@types/node": {
924
+ "optional": true
925
+ },
926
+ "@vitejs/devtools": {
927
+ "optional": true
928
+ },
929
+ "esbuild": {
930
+ "optional": true
931
+ },
932
+ "jiti": {
933
+ "optional": true
934
+ },
935
+ "less": {
936
+ "optional": true
937
+ },
938
+ "sass": {
939
+ "optional": true
940
+ },
941
+ "sass-embedded": {
942
+ "optional": true
943
+ },
944
+ "stylus": {
945
+ "optional": true
946
+ },
947
+ "sugarss": {
948
+ "optional": true
949
+ },
950
+ "terser": {
951
+ "optional": true
952
+ },
953
+ "tsx": {
954
+ "optional": true
955
+ },
956
+ "yaml": {
957
+ "optional": true
958
+ }
959
+ }
960
+ }
961
+ }
962
+ }
frontend/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "devDependencies": {
12
+ "@vitejs/plugin-react": "^6.0.1",
13
+ "typescript": "~6.0.2",
14
+ "vite": "^8.0.10"
15
+ },
16
+ "dependencies": {
17
+ "lucide-react": "^1.11.0",
18
+ "react": "^19.2.5",
19
+ "react-dom": "^19.2.5"
20
+ }
21
+ }
frontend/public/favicon.svg ADDED
frontend/public/icons.svg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap');
2
+
3
+ :root {
4
+ --bg-gradient: linear-gradient(135deg, #09090b 0%, #1e1b4b 100%);
5
+ --glass-bg: rgba(15, 23, 42, 0.4);
6
+ --glass-border: rgba(255, 255, 255, 0.08);
7
+ --neon-blue: #38bdf8;
8
+ --neon-blue-glow: rgba(56, 189, 248, 0.6);
9
+ --neon-pink: #f472b6;
10
+ --neon-pink-glow: rgba(244, 114, 182, 0.6);
11
+ --neon-purple: #c084fc;
12
+ --neon-purple-glow: rgba(192, 132, 252, 0.6);
13
+ --neon-green: #34d399;
14
+ --neon-green-glow: rgba(52, 211, 153, 0.6);
15
+ --asphalt: #18181b;
16
+ --line-color: rgba(255,255,255,0.15);
17
+ --stop-line: #cbd5e1;
18
+ }
19
+
20
+ body {
21
+ margin: 0; padding: 0;
22
+ font-family: 'Outfit', sans-serif;
23
+ background: var(--bg-gradient);
24
+ color: white;
25
+ min-height: 100vh;
26
+ overflow-x: hidden;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+
31
+ /* Moving background blobs */
32
+ .bg-blob { position: fixed; border-radius: 50%; filter: blur(100px); z-index: -1; opacity: 0.4; animation: float 10s infinite alternate; }
33
+ .blob-1 { width: 400px; height: 400px; background: rgba(56, 189, 248, 0.2); top: 10%; left: 10%; }
34
+ .blob-2 { width: 500px; height: 500px; background: rgba(192, 132, 252, 0.15); bottom: 10%; right: 10%; }
35
+ .blob-3 { width: 300px; height: 300px; background: rgba(52, 211, 153, 0.15); bottom: 50%; left: 40%; animation-delay: -5s; }
36
+
37
+ @keyframes float { 100% { transform: translateY(50px) translateX(50px); } }
38
+
39
+ /* Layout */
40
+ .app-container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
41
+
42
+ .premium-header {
43
+ display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;
44
+ padding: 1.5rem 2rem; background: var(--glass-bg); border: 1px solid var(--glass-border);
45
+ border-radius: 16px; backdrop-filter: blur(20px);
46
+ }
47
+ .header-brand { display: flex; align-items: center; gap: 1rem; }
48
+ .icon-wrapper { background: rgba(56,189,248,0.1); color: var(--neon-blue); padding: 12px; border-radius: 12px; border: 1px solid rgba(56,189,248,0.2); }
49
+ .header-brand h1 { margin: 0; font-size: 1.5rem; font-weight: 800; letter-spacing: 1px; }
50
+ .header-brand p { margin: 0; color: #94a3b8; font-size: 0.875rem; font-weight: 300; }
51
+
52
+ .header-status { display: flex; align-items: center; gap: 0.75rem; background: rgba(0,0,0,0.3); padding: 0.5rem 1.25rem; border-radius: 999px; border: 1px solid var(--glass-border); font-size: 0.875rem; font-weight: 600; letter-spacing: 1px; }
53
+ .pulse-dot { width: 8px; height: 8px; background: var(--neon-green); border-radius: 50%; box-shadow: 0 0 10px var(--neon-green); animation: pulse 2s infinite; }
54
+ @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.3; } 100% { opacity: 1; } }
55
+
56
+ .main-layout { display: grid; grid-template-columns: 1fr 380px; gap: 2rem; }
57
+
58
+ /* INTERSECTION CENTERPIECE */
59
+ .intersection-wrapper {
60
+ position: relative; border-radius: 24px; padding: 2rem; display: flex; flex-direction: column; align-items: center;
61
+ background: rgba(0,0,0,0.2); border: 1px solid var(--glass-border); box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5);
62
+ overflow: hidden;
63
+ }
64
+
65
+ .intersection-container {
66
+ position: relative; width: 600px; height: 600px; background: #000; border-radius: 20px;
67
+ box-shadow: inset 0 0 50px rgba(0,0,0,0.8); overflow: hidden; margin-bottom: 2rem;
68
+ }
69
+
70
+ .road { position: absolute; background: var(--asphalt); }
71
+ .road-vertical { width: 160px; height: 100%; left: 50%; transform: translateX(-50%); border-left: 2px solid var(--line-color); border-right: 2px solid var(--line-color); }
72
+ .road-horizontal { width: 100%; height: 160px; top: 50%; transform: translateY(-50%); border-top: 2px solid var(--line-color); border-bottom: 2px solid var(--line-color); }
73
+
74
+ .lane-divider { position: absolute; }
75
+ .vertical-left, .vertical-right { width: 2px; height: 100%; background: var(--line-color); background-image: linear-gradient(to bottom, var(--line-color) 50%, transparent 50%); background-size: 100% 30px; }
76
+ .vertical-left { left: 53px; }
77
+ .vertical-right { left: 106px; }
78
+
79
+ .horizontal-top, .horizontal-bottom { width: 100%; height: 2px; background: var(--line-color); background-image: linear-gradient(to right, var(--line-color) 50%, transparent 50%); background-size: 30px 100%; }
80
+ .horizontal-top { top: 53px; }
81
+ .horizontal-bottom { top: 106px; }
82
+
83
+ .stop-line { position: absolute; background: var(--stop-line); }
84
+ .sl-top { width: 160px; height: 4px; top: 220px; }
85
+ .sl-bottom { width: 160px; height: 4px; bottom: 220px; }
86
+ .sl-left { height: 160px; width: 4px; left: 220px; }
87
+ .sl-right { height: 160px; width: 4px; right: 220px; }
88
+
89
+ .intersection-center {
90
+ position: absolute; width: 160px; height: 160px; background: var(--asphalt);
91
+ left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2;
92
+ display: flex; align-items: center; justify-content: center;
93
+ }
94
+ .center-logo {
95
+ width: 80px; height: 80px; border-radius: 50%; border: 2px dashed rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: center;
96
+ }
97
+
98
+ /* Premium Traffic Lights */
99
+ .traffic-fixture {
100
+ position: absolute; display: flex; background: #27272a; padding: 8px; border-radius: 12px;
101
+ gap: 8px; z-index: 10; border: 2px solid #3f3f46; box-shadow: 0 10px 20px rgba(0,0,0,0.5), inset 0 2px 5px rgba(255,255,255,0.1);
102
+ }
103
+ .t-light { width: 16px; height: 16px; border-radius: 50%; background: #000; box-shadow: inset 0 2px 4px rgba(0,0,0,0.8); transition: all 0.3s; }
104
+ .t-light.red.active { background: #ef4444; box-shadow: 0 0 20px #ef4444, inset 0 0 5px #fff; }
105
+ .t-light.green.active { background: #10b981; box-shadow: 0 0 20px #10b981, inset 0 0 5px #fff; }
106
+
107
+ .tf-north { flex-direction: column; top: 120px; left: 140px; }
108
+ .tf-south { flex-direction: column; bottom: 120px; right: 140px; }
109
+ .tf-east { top: 140px; right: 120px; }
110
+ .tf-west { bottom: 140px; left: 120px; }
111
+
112
+ /* Vehicles */
113
+ .queue { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; }
114
+ .car { position: absolute; border-radius: 6px; display: flex; justify-content: center; align-items: center; }
115
+ .car-glow { width: 100%; height: 100%; border-radius: inherit; }
116
+
117
+ .car-n { width: 22px; height: 40px; }
118
+ .car-n .car-glow { background: linear-gradient(180deg, var(--neon-blue) 0%, #1e40af 100%); box-shadow: 0 0 15px var(--neon-blue-glow); }
119
+ .car-n.straight { bottom: calc(50% + 80px + (var(--idx) * 45px)); left: calc(50% - 66px); }
120
+ .car-n.left-turn { bottom: calc(50% + 80px + (var(--idx) * 45px)); left: calc(50% - 14px); }
121
+
122
+ .car-s { width: 22px; height: 40px; }
123
+ .car-s .car-glow { background: linear-gradient(0deg, var(--neon-pink) 0%, #9d174d 100%); box-shadow: 0 0 15px var(--neon-pink-glow); }
124
+ .car-s.straight { top: calc(50% + 80px + (var(--idx) * 45px)); left: calc(50% + 44px); }
125
+ .car-s.left-turn { top: calc(50% + 80px + (var(--idx) * 45px)); left: calc(50% - 8px); }
126
+
127
+ .car-e { width: 40px; height: 22px; }
128
+ .car-e .car-glow { background: linear-gradient(270deg, var(--neon-purple) 0%, #5b21b6 100%); box-shadow: 0 0 15px var(--neon-purple-glow); }
129
+ .car-e.straight { left: calc(50% + 80px + (var(--idx) * 45px)); top: calc(50% - 66px); }
130
+ .car-e.left-turn { left: calc(50% + 80px + (var(--idx) * 45px)); top: calc(50% - 14px); }
131
+
132
+ .car-w { width: 40px; height: 22px; }
133
+ .car-w .car-glow { background: linear-gradient(90deg, var(--neon-green) 0%, #065f46 100%); box-shadow: 0 0 15px var(--neon-green-glow); }
134
+ .car-w.straight { right: calc(50% + 80px + (var(--idx) * 45px)); top: calc(50% + 44px); }
135
+ .car-w.left-turn { right: calc(50% + 80px + (var(--idx) * 45px)); top: calc(50% - 8px); }
136
+
137
+ /* Animations */
138
+ @keyframes pSV { to { transform: translateY(300px); opacity: 0; } }
139
+ @keyframes pSVU { to { transform: translateY(-300px); opacity: 0; } }
140
+ @keyframes pSH { to { transform: translateX(-300px); opacity: 0; } }
141
+ @keyframes pSHR { to { transform: translateX(300px); opacity: 0; } }
142
+ @keyframes tLN { to { transform: translate(150px, 150px) rotate(-90deg); opacity: 0; } }
143
+ @keyframes tLS { to { transform: translate(-150px, -150px) rotate(-90deg); opacity: 0; } }
144
+ @keyframes tLE { to { transform: translate(-150px, 150px) rotate(-90deg); opacity: 0; } }
145
+ @keyframes tLW { to { transform: translate(150px, -150px) rotate(-90deg); opacity: 0; } }
146
+
147
+ .car-n.straight.animating { animation: pSV 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
148
+ .car-n.left-turn.animating { animation: tLN 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
149
+ .car-s.straight.animating { animation: pSVU 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
150
+ .car-s.left-turn.animating { animation: tLS 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
151
+ .car-e.straight.animating { animation: pSH 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
152
+ .car-e.left-turn.animating { animation: tLE 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
153
+ .car-w.straight.animating { animation: pSHR 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
154
+ .car-w.left-turn.animating { animation: tLW 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
155
+
156
+ /* Control Bar */
157
+ .premium-controls {
158
+ display: flex; gap: 1rem; background: rgba(0,0,0,0.4); padding: 1rem 2rem; border-radius: 999px;
159
+ border: 1px solid var(--glass-border); box-shadow: 0 10px 30px rgba(0,0,0,0.5); backdrop-filter: blur(10px);
160
+ }
161
+ .p-btn {
162
+ display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; border: none; border-radius: 999px;
163
+ font-weight: 700; cursor: pointer; transition: all 0.2s; font-family: 'Outfit', sans-serif; letter-spacing: 1px;
164
+ }
165
+ .btn-play { background: var(--neon-blue); color: #000; box-shadow: 0 0 15px var(--neon-blue-glow); }
166
+ .btn-play:hover { transform: translateY(-2px); box-shadow: 0 0 25px var(--neon-blue-glow); }
167
+ .btn-pause { background: var(--neon-pink); color: #000; box-shadow: 0 0 15px var(--neon-pink-glow); }
168
+ .btn-ghost { background: transparent; color: white; border: 1px solid rgba(255,255,255,0.2); }
169
+ .btn-ghost:hover:not(:disabled) { background: rgba(255,255,255,0.1); }
170
+ .btn-ghost:disabled { opacity: 0.3; cursor: not-allowed; }
171
+ .control-divider { width: 1px; background: rgba(255,255,255,0.1); margin: 0 0.5rem; }
172
+ .p-select { background: transparent; color: white; border: none; outline: none; font-family: 'Outfit'; font-weight: 600; cursor: pointer; }
173
+ .p-select option { background: #18181b; }
174
+
175
+ /* Sidebar Metrics */
176
+ .metrics-sidebar { display: flex; flex-direction: column; gap: 1.5rem; }
177
+ .glass-card { background: var(--glass-bg); backdrop-filter: blur(20px); border: 1px solid var(--glass-border); border-radius: 20px; padding: 1.5rem; }
178
+ .glass-card h3 { margin: 0 0 1rem 0; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 2px; color: #94a3b8; }
179
+
180
+ .primary-card { background: linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(15, 23, 42, 0.4) 100%); border: 1px solid rgba(56,189,248,0.3); }
181
+ .big-metric { display: flex; flex-direction: column; margin-bottom: 1rem; }
182
+ .big-metric .value { font-size: 3.5rem; font-weight: 800; line-height: 1; color: var(--neon-blue); text-shadow: 0 0 20px var(--neon-blue-glow); }
183
+ .big-metric .label { font-size: 0.875rem; color: #cbd5e1; margin-top: 0.5rem; font-weight: 300; }
184
+
185
+ .mini-progress { width: 100%; height: 4px; background: rgba(0,0,0,0.3); border-radius: 2px; overflow: hidden; margin-bottom: 0.5rem; }
186
+ .mini-progress .fill { height: 100%; background: var(--neon-blue); box-shadow: 0 0 10px var(--neon-blue); transition: width 0.3s; }
187
+ .step-count { font-size: 0.75rem; color: #64748b; font-weight: 600; }
188
+
189
+ .reward-value { font-size: 2.5rem; font-weight: 800; }
190
+ .reward-value.positive { color: var(--neon-green); text-shadow: 0 0 15px var(--neon-green-glow); }
191
+ .reward-value.negative { color: var(--neon-pink); text-shadow: 0 0 15px var(--neon-pink-glow); }
192
+ .reward-desc { font-size: 0.875rem; color: #94a3b8; margin: 0.5rem 0 0 0; line-height: 1.4; }
193
+
194
+ .telemetry-grid { display: flex; flex-direction: column; gap: 0.75rem; }
195
+ .t-item { background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.05); padding: 1rem; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; transition: all 0.3s; }
196
+ .t-item.active-lane { background: rgba(52, 211, 153, 0.1); border-color: rgba(52, 211, 153, 0.3); box-shadow: 0 0 20px rgba(52, 211, 153, 0.1); }
197
+ .t-head { font-weight: 700; letter-spacing: 1px; color: #cbd5e1; }
198
+ .t-item.active-lane .t-head { color: var(--neon-green); }
199
+ .t-data { font-family: monospace; font-size: 1.1rem; }
200
+ .t-data span { color: #475569; margin: 0 0.5rem; }
frontend/src/App.jsx ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Play, Pause, RotateCcw, FastForward, Activity, MapPin } from 'lucide-react';
3
+ import './App.css';
4
+
5
+ const API_BASE = window.location.origin === 'http://localhost:5173' || window.location.origin === 'http://127.0.0.1:5173'
6
+ ? 'http://127.0.0.1:8000/api'
7
+ : '/api';
8
+
9
+
10
+ function App() {
11
+ const [state, setState] = useState({
12
+ queues: [0, 0, 0, 0, 0, 0, 0, 0], // N_SR, N_L, E_SR, E_L, S_SR, S_L, W_SR, W_L
13
+ phase: 0,
14
+ reward: 0,
15
+ vehicles_passed: 0,
16
+ step: 0,
17
+ total_reward: 0,
18
+ is_done: false
19
+ });
20
+
21
+ const [isPlaying, setIsPlaying] = useState(false);
22
+ const [speedMs, setSpeedMs] = useState(250);
23
+
24
+ const fetchState = async (endpoint) => {
25
+ try {
26
+ const res = await fetch(`${API_BASE}/${endpoint}`, { method: 'POST' });
27
+ const data = await res.json();
28
+ setState(data);
29
+ if (data.is_done) {
30
+ setIsPlaying(false);
31
+ }
32
+ } catch (err) {
33
+ console.error(err);
34
+ setIsPlaying(false);
35
+ }
36
+ };
37
+
38
+ useEffect(() => {
39
+ fetchState('reset');
40
+ }, []);
41
+
42
+ useEffect(() => {
43
+ let interval;
44
+ if (isPlaying) {
45
+ interval = setInterval(() => {
46
+ fetchState('step');
47
+ }, speedMs);
48
+ }
49
+ return () => clearInterval(interval);
50
+ }, [isPlaying, speedMs]);
51
+
52
+ // Phase logic: 0=N, 1=E, 2=S, 3=W
53
+ const isNGreen = state.phase === 0;
54
+ const isEGreen = state.phase === 1;
55
+ const isSGreen = state.phase === 2;
56
+ const isWGreen = state.phase === 3;
57
+
58
+ const renderCars = (count, direction, isLeftTurn) => {
59
+ const displayCount = Math.min(count, 5); // Cap visuals for ultra-premium look
60
+ return Array.from({ length: displayCount }).map((_, i) => (
61
+ <div
62
+ key={i}
63
+ className={`car car-${direction} ${isLeftTurn ? 'left-turn' : 'straight'} ${i === 0 && ((direction==='n'&&isNGreen)||(direction==='e'&&isEGreen)||(direction==='s'&&isSGreen)||(direction==='w'&&isWGreen)) && isPlaying ? 'animating' : ''}`}
64
+ style={{ '--idx': i }}
65
+ >
66
+ <div className="car-glow"></div>
67
+ </div>
68
+ ));
69
+ };
70
+
71
+ const getPhaseName = () => {
72
+ if (isNGreen) return "NORTH GREEN";
73
+ if (isEGreen) return "EAST GREEN";
74
+ if (isSGreen) return "SOUTH GREEN";
75
+ if (isWGreen) return "WEST GREEN";
76
+ };
77
+
78
+ return (
79
+ <>
80
+ <div className="bg-blob blob-1"></div>
81
+ <div className="bg-blob blob-2"></div>
82
+ <div className="bg-blob blob-3"></div>
83
+
84
+ <div className="app-container">
85
+ <header className="premium-header">
86
+ <div className="header-brand">
87
+ <div className="icon-wrapper">
88
+ <Activity size={28} />
89
+ </div>
90
+ <div>
91
+ <h1>Nexus Control</h1>
92
+ <p>Deep Q-Network Traffic Simulation</p>
93
+ </div>
94
+ </div>
95
+ <div className="header-status">
96
+ <div className="pulse-dot"></div>
97
+ <span>System Active • {getPhaseName()}</span>
98
+ </div>
99
+ </header>
100
+
101
+ <main className="main-layout">
102
+ {/* INTERSECTION CENTERPIECE */}
103
+ <div className="intersection-wrapper glass-panel">
104
+ <div className="intersection-container">
105
+ {/* Roads */}
106
+ <div className="road road-vertical">
107
+ <div className="lane-divider vertical-left"></div>
108
+ <div className="lane-divider vertical-right"></div>
109
+ <div className="stop-line sl-top"></div>
110
+ <div className="stop-line sl-bottom"></div>
111
+ </div>
112
+ <div className="road road-horizontal">
113
+ <div className="lane-divider horizontal-top"></div>
114
+ <div className="lane-divider horizontal-bottom"></div>
115
+ <div className="stop-line sl-left"></div>
116
+ <div className="stop-line sl-right"></div>
117
+ </div>
118
+ <div className="intersection-center">
119
+ <div className="center-logo"><MapPin size={24} opacity={0.2} /></div>
120
+ </div>
121
+
122
+ {/* Traffic Light Fixtures */}
123
+ <div className="traffic-fixture tf-north">
124
+ <div className={`t-light red ${!isNGreen ? 'active' : ''}`}></div>
125
+ <div className={`t-light green ${isNGreen ? 'active' : ''}`}></div>
126
+ </div>
127
+ <div className="traffic-fixture tf-east">
128
+ <div className={`t-light red ${!isEGreen ? 'active' : ''}`}></div>
129
+ <div className={`t-light green ${isEGreen ? 'active' : ''}`}></div>
130
+ </div>
131
+ <div className="traffic-fixture tf-south">
132
+ <div className={`t-light red ${!isSGreen ? 'active' : ''}`}></div>
133
+ <div className={`t-light green ${isSGreen ? 'active' : ''}`}></div>
134
+ </div>
135
+ <div className="traffic-fixture tf-west">
136
+ <div className={`t-light red ${!isWGreen ? 'active' : ''}`}></div>
137
+ <div className={`t-light green ${isWGreen ? 'active' : ''}`}></div>
138
+ </div>
139
+
140
+ {/* Vehicles */}
141
+ <div className="queue q-north">
142
+ {renderCars(state.queues[0], 'n', false)}
143
+ {renderCars(state.queues[1], 'n', true)}
144
+ </div>
145
+ <div className="queue q-east">
146
+ {renderCars(state.queues[2], 'e', false)}
147
+ {renderCars(state.queues[3], 'e', true)}
148
+ </div>
149
+ <div className="queue q-south">
150
+ {renderCars(state.queues[4], 's', false)}
151
+ {renderCars(state.queues[5], 's', true)}
152
+ </div>
153
+ <div className="queue q-west">
154
+ {renderCars(state.queues[6], 'w', false)}
155
+ {renderCars(state.queues[7], 'w', true)}
156
+ </div>
157
+ </div>
158
+
159
+ {/* Control Bar Overlay */}
160
+ <div className="premium-controls">
161
+ <button onClick={() => setIsPlaying(!isPlaying)} className={`p-btn ${isPlaying ? 'btn-pause' : 'btn-play'}`}>
162
+ {isPlaying ? <><Pause size={20}/> PAUSE</> : <><Play size={20}/> START</>}
163
+ </button>
164
+ <div className="control-divider"></div>
165
+ <button onClick={() => fetchState('step')} disabled={isPlaying} className="p-btn btn-ghost">
166
+ <FastForward size={18}/> STEP
167
+ </button>
168
+ <button onClick={() => fetchState('reset')} className="p-btn btn-ghost">
169
+ <RotateCcw size={18}/> RESET
170
+ </button>
171
+ <div className="control-divider"></div>
172
+ <select value={speedMs} onChange={(e) => setSpeedMs(Number(e.target.value))} className="p-select">
173
+ <option value={500}>0.5x Speed</option>
174
+ <option value={250}>1.0x Speed</option>
175
+ <option value={100}>2.0x Speed</option>
176
+ </select>
177
+ </div>
178
+ </div>
179
+
180
+ {/* METRICS SIDEBAR */}
181
+ <aside className="metrics-sidebar">
182
+ <div className="glass-card primary-card">
183
+ <h3>AI Performance</h3>
184
+ <div className="big-metric">
185
+ <span className="value">{state.vehicles_passed}</span>
186
+ <span className="label">Total Vehicles Cleared</span>
187
+ </div>
188
+ <div className="mini-progress">
189
+ <div className="fill" style={{ width: `${(state.step/3600)*100}%` }}></div>
190
+ </div>
191
+ <div className="step-count">Time: {state.step} / 3600</div>
192
+ </div>
193
+
194
+ <div className="glass-card">
195
+ <h3>Reward Signal</h3>
196
+ <div className={`reward-value ${state.total_reward < -50 ? 'negative' : 'positive'}`}>
197
+ {state.total_reward.toFixed(2)}
198
+ </div>
199
+ <p className="reward-desc">Optimizing to keep queues at absolute minimums.</p>
200
+ </div>
201
+
202
+ <div className="glass-card queues-card">
203
+ <h3>Live Lane Telemetry</h3>
204
+ <div className="telemetry-grid">
205
+ <div className={`t-item ${isNGreen ? 'active-lane' : ''}`}>
206
+ <div className="t-head">NORTH</div>
207
+ <div className="t-data">SR: {state.queues[0]} <span>|</span> L: {state.queues[1]}</div>
208
+ </div>
209
+ <div className={`t-item ${isEGreen ? 'active-lane' : ''}`}>
210
+ <div className="t-head">EAST</div>
211
+ <div className="t-data">SR: {state.queues[2]} <span>|</span> L: {state.queues[3]}</div>
212
+ </div>
213
+ <div className={`t-item ${isSGreen ? 'active-lane' : ''}`}>
214
+ <div className="t-head">SOUTH</div>
215
+ <div className="t-data">SR: {state.queues[4]} <span>|</span> L: {state.queues[5]}</div>
216
+ </div>
217
+ <div className={`t-item ${isWGreen ? 'active-lane' : ''}`}>
218
+ <div className="t-head">WEST</div>
219
+ <div className="t-data">SR: {state.queues[6]} <span>|</span> L: {state.queues[7]}</div>
220
+ </div>
221
+ </div>
222
+ </div>
223
+ </aside>
224
+ </main>
225
+ </div>
226
+ </>
227
+ );
228
+ }
229
+
230
+ export default App;
frontend/src/assets/hero.png ADDED
frontend/src/assets/typescript.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/counter.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export function setupCounter(element: HTMLButtonElement) {
2
+ let counter = 0
3
+ const setCounter = (count: number) => {
4
+ counter = count
5
+ element.innerHTML = `Count is ${counter}`
6
+ }
7
+ element.addEventListener('click', () => setCounter(counter + 1))
8
+ setCounter(0)
9
+ }
frontend/src/index.css ADDED
@@ -0,0 +1 @@
 
 
1
+ /* Cleared to use App.css */
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('app')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/main.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './style.css'
2
+ import typescriptLogo from './assets/typescript.svg'
3
+ import viteLogo from './assets/vite.svg'
4
+ import heroImg from './assets/hero.png'
5
+ import { setupCounter } from './counter.ts'
6
+
7
+ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
8
+ <section id="center">
9
+ <div class="hero">
10
+ <img src="${heroImg}" class="base" width="170" height="179">
11
+ <img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
12
+ <img src="${viteLogo}" class="vite" alt="Vite logo" />
13
+ </div>
14
+ <div>
15
+ <h1>Get started</h1>
16
+ <p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
17
+ </div>
18
+ <button id="counter" type="button" class="counter"></button>
19
+ </section>
20
+
21
+ <div class="ticks"></div>
22
+
23
+ <section id="next-steps">
24
+ <div id="docs">
25
+ <svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
26
+ <h2>Documentation</h2>
27
+ <p>Your questions, answered</p>
28
+ <ul>
29
+ <li>
30
+ <a href="https://vite.dev/" target="_blank">
31
+ <img class="logo" src="${viteLogo}" alt="" />
32
+ Explore Vite
33
+ </a>
34
+ </li>
35
+ <li>
36
+ <a href="https://www.typescriptlang.org" target="_blank">
37
+ <img class="button-icon" src="${typescriptLogo}" alt="">
38
+ Learn more
39
+ </a>
40
+ </li>
41
+ </ul>
42
+ </div>
43
+ <div id="social">
44
+ <svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
45
+ <h2>Connect with us</h2>
46
+ <p>Join the Vite community</p>
47
+ <ul>
48
+ <li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
49
+ <li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
50
+ <li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
51
+ <li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
52
+ </ul>
53
+ </div>
54
+ </section>
55
+
56
+ <div class="ticks"></div>
57
+ <section id="spacer"></section>
58
+ `
59
+
60
+ setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
frontend/src/style.css ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --text: #6b6375;
3
+ --text-h: #08060d;
4
+ --bg: #fff;
5
+ --border: #e5e4e7;
6
+ --code-bg: #f4f3ec;
7
+ --accent: #aa3bff;
8
+ --accent-bg: rgba(170, 59, 255, 0.1);
9
+ --accent-border: rgba(170, 59, 255, 0.5);
10
+ --social-bg: rgba(244, 243, 236, 0.5);
11
+ --shadow:
12
+ rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
13
+
14
+ --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
15
+ --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
16
+ --mono: ui-monospace, Consolas, monospace;
17
+
18
+ font: 18px/145% var(--sans);
19
+ letter-spacing: 0.18px;
20
+ color-scheme: light dark;
21
+ color: var(--text);
22
+ background: var(--bg);
23
+ font-synthesis: none;
24
+ text-rendering: optimizeLegibility;
25
+ -webkit-font-smoothing: antialiased;
26
+ -moz-osx-font-smoothing: grayscale;
27
+
28
+ @media (max-width: 1024px) {
29
+ font-size: 16px;
30
+ }
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ :root {
35
+ --text: #9ca3af;
36
+ --text-h: #f3f4f6;
37
+ --bg: #16171d;
38
+ --border: #2e303a;
39
+ --code-bg: #1f2028;
40
+ --accent: #c084fc;
41
+ --accent-bg: rgba(192, 132, 252, 0.15);
42
+ --accent-border: rgba(192, 132, 252, 0.5);
43
+ --social-bg: rgba(47, 48, 58, 0.5);
44
+ --shadow:
45
+ rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
46
+ }
47
+
48
+ #social .button-icon {
49
+ filter: invert(1) brightness(2);
50
+ }
51
+ }
52
+
53
+ body {
54
+ margin: 0;
55
+ }
56
+
57
+ h1,
58
+ h2 {
59
+ font-family: var(--heading);
60
+ font-weight: 500;
61
+ color: var(--text-h);
62
+ }
63
+
64
+ h1 {
65
+ font-size: 56px;
66
+ letter-spacing: -1.68px;
67
+ margin: 32px 0;
68
+ @media (max-width: 1024px) {
69
+ font-size: 36px;
70
+ margin: 20px 0;
71
+ }
72
+ }
73
+ h2 {
74
+ font-size: 24px;
75
+ line-height: 118%;
76
+ letter-spacing: -0.24px;
77
+ margin: 0 0 8px;
78
+ @media (max-width: 1024px) {
79
+ font-size: 20px;
80
+ }
81
+ }
82
+ p {
83
+ margin: 0;
84
+ }
85
+
86
+ code,
87
+ .counter {
88
+ font-family: var(--mono);
89
+ display: inline-flex;
90
+ border-radius: 4px;
91
+ color: var(--text-h);
92
+ }
93
+
94
+ code {
95
+ font-size: 15px;
96
+ line-height: 135%;
97
+ padding: 4px 8px;
98
+ background: var(--code-bg);
99
+ }
100
+
101
+ .counter {
102
+ font-size: 16px;
103
+ padding: 5px 10px;
104
+ border-radius: 5px;
105
+ color: var(--accent);
106
+ background: var(--accent-bg);
107
+ border: 2px solid transparent;
108
+ transition: border-color 0.3s;
109
+ margin-bottom: 24px;
110
+
111
+ &:hover {
112
+ border-color: var(--accent-border);
113
+ }
114
+ &:focus-visible {
115
+ outline: 2px solid var(--accent);
116
+ outline-offset: 2px;
117
+ }
118
+ }
119
+
120
+ .hero {
121
+ position: relative;
122
+
123
+ .base,
124
+ .framework,
125
+ .vite {
126
+ inset-inline: 0;
127
+ margin: 0 auto;
128
+ }
129
+
130
+ .base {
131
+ width: 170px;
132
+ position: relative;
133
+ z-index: 0;
134
+ }
135
+
136
+ .framework,
137
+ .vite {
138
+ position: absolute;
139
+ }
140
+
141
+ .framework {
142
+ z-index: 1;
143
+ top: 34px;
144
+ height: 28px;
145
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
146
+ scale(1.4);
147
+ }
148
+
149
+ .vite {
150
+ z-index: 0;
151
+ top: 107px;
152
+ height: 26px;
153
+ width: auto;
154
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
155
+ scale(0.8);
156
+ }
157
+ }
158
+
159
+ #app {
160
+ width: 1126px;
161
+ max-width: 100%;
162
+ margin: 0 auto;
163
+ text-align: center;
164
+ border-inline: 1px solid var(--border);
165
+ min-height: 100svh;
166
+ display: flex;
167
+ flex-direction: column;
168
+ box-sizing: border-box;
169
+ }
170
+
171
+ #center {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 25px;
175
+ place-content: center;
176
+ place-items: center;
177
+ flex-grow: 1;
178
+
179
+ @media (max-width: 1024px) {
180
+ padding: 32px 20px 24px;
181
+ gap: 18px;
182
+ }
183
+ }
184
+
185
+ #next-steps {
186
+ display: flex;
187
+ border-top: 1px solid var(--border);
188
+ text-align: left;
189
+
190
+ & > div {
191
+ flex: 1 1 0;
192
+ padding: 32px;
193
+ @media (max-width: 1024px) {
194
+ padding: 24px 20px;
195
+ }
196
+ }
197
+
198
+ .icon {
199
+ margin-bottom: 16px;
200
+ width: 22px;
201
+ height: 22px;
202
+ }
203
+
204
+ @media (max-width: 1024px) {
205
+ flex-direction: column;
206
+ text-align: center;
207
+ }
208
+ }
209
+
210
+ #docs {
211
+ border-right: 1px solid var(--border);
212
+
213
+ @media (max-width: 1024px) {
214
+ border-right: none;
215
+ border-bottom: 1px solid var(--border);
216
+ }
217
+ }
218
+
219
+ #next-steps ul {
220
+ list-style: none;
221
+ padding: 0;
222
+ display: flex;
223
+ gap: 8px;
224
+ margin: 32px 0 0;
225
+
226
+ .logo {
227
+ height: 18px;
228
+ }
229
+
230
+ a {
231
+ color: var(--text-h);
232
+ font-size: 16px;
233
+ border-radius: 6px;
234
+ background: var(--social-bg);
235
+ display: flex;
236
+ padding: 6px 12px;
237
+ align-items: center;
238
+ gap: 8px;
239
+ text-decoration: none;
240
+ transition: box-shadow 0.3s;
241
+
242
+ &:hover {
243
+ box-shadow: var(--shadow);
244
+ }
245
+ .button-icon {
246
+ height: 18px;
247
+ width: 18px;
248
+ }
249
+ }
250
+
251
+ @media (max-width: 1024px) {
252
+ margin-top: 20px;
253
+ flex-wrap: wrap;
254
+ justify-content: center;
255
+
256
+ li {
257
+ flex: 1 1 calc(50% - 8px);
258
+ }
259
+
260
+ a {
261
+ width: 100%;
262
+ justify-content: center;
263
+ box-sizing: border-box;
264
+ }
265
+ }
266
+ }
267
+
268
+ #spacer {
269
+ height: 88px;
270
+ border-top: 1px solid var(--border);
271
+ @media (max-width: 1024px) {
272
+ height: 48px;
273
+ }
274
+ }
275
+
276
+ .ticks {
277
+ position: relative;
278
+ width: 100%;
279
+
280
+ &::before,
281
+ &::after {
282
+ content: '';
283
+ position: absolute;
284
+ top: -4.5px;
285
+ border: 5px solid transparent;
286
+ }
287
+
288
+ &::before {
289
+ left: 0;
290
+ border-left-color: var(--border);
291
+ }
292
+ &::after {
293
+ right: 0;
294
+ border-right-color: var(--border);
295
+ }
296
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "module": "esnext",
5
+ "lib": ["ES2023", "DOM"],
6
+ "types": ["vite/client"],
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "erasableSyntaxOnly": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["src"]
23
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
main.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py — RL Traffic Signal Control entry point.
3
+
4
+ Automated pipeline (recommended):
5
+ python main.py --auto
6
+
7
+ Manual usage:
8
+ python main.py --mode train --agent q_learning --episodes 50
9
+ python main.py --mode train --agent dqn --episodes 150
10
+ python main.py --mode eval --agent q_learning --model-path models/q_learning_best.pth
11
+ python main.py --mode eval --agent dqn --model-path models/dqn_best.pth
12
+ python main.py --mode fixed # Fixed-signal baseline only
13
+
14
+ The --auto flag runs the full pipeline:
15
+ 1. Fixed-signal baseline (10 episodes)
16
+ 2. Q-Learning training (50 episodes)
17
+ 3. DQN training (150 episodes)
18
+ 4. Evaluation & comparison plots
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+
29
+ # ── Project imports ───────────────────────────────────────────────────────────
30
+ import config as cfg
31
+ from environment import TrafficEnvironment
32
+ from agent import QLearningAgent, DQNAgent, DQN_AVAILABLE
33
+ from training import Trainer, Evaluator
34
+ from utils import setup_logger, MetricsTracker, plot_training_curves
35
+ from utils.visualizer import plot_comparison, plot_bar_comparison
36
+
37
+ logger = setup_logger("main")
38
+
39
+
40
+ # ═══════════════════════════════════════════════════════════════════════════════
41
+ # Factory helpers
42
+ # ═══════════════════════════════════════════════════════════════════════════════
43
+
44
+ def make_env() -> TrafficEnvironment:
45
+ """Create a fresh environment instance."""
46
+ return TrafficEnvironment(cfg)
47
+
48
+
49
+ def make_q_learning_agent() -> QLearningAgent:
50
+ """Instantiate a Q-Learning agent using project config."""
51
+ return QLearningAgent(
52
+ state_size=cfg.STATE_SIZE,
53
+ action_size=cfg.ACTION_SIZE,
54
+ config=cfg.Q_LEARNING_CONFIG,
55
+ )
56
+
57
+
58
+ def make_dqn_agent():
59
+ """Instantiate a DQN agent using project config (requires PyTorch)."""
60
+ if not DQN_AVAILABLE:
61
+ logger.error("PyTorch is not installed — DQN unavailable.")
62
+ logger.error("Install with: pip install torch")
63
+ sys.exit(1)
64
+ return DQNAgent(
65
+ state_size=cfg.STATE_SIZE,
66
+ action_size=cfg.ACTION_SIZE,
67
+ config=cfg.DQN_CONFIG,
68
+ )
69
+
70
+
71
+ # ═══════════════════════════════════════════════════════════════════════════════
72
+ # Fixed-signal baseline
73
+ # ═══════════════════════════════════════════════════════════════════════════════
74
+
75
+ class FixedSignalAgent:
76
+ """
77
+ Round-robin fixed-timing signal — cycles phases every 30 steps.
78
+ Used as the comparison baseline.
79
+ """
80
+
81
+ def __init__(self, switch_interval: int = 30):
82
+ self.switch_interval = switch_interval
83
+ self._step = 0
84
+
85
+ def select_action(self, state, training: bool = False) -> int:
86
+ self._step += 1
87
+ return 1 if self._step % self.switch_interval == 0 else 0
88
+
89
+ def train_step(self, *args, **kwargs):
90
+ return None
91
+
92
+ def save(self, filepath):
93
+ pass
94
+
95
+ def load(self, filepath):
96
+ pass
97
+
98
+ def reset(self):
99
+ self._step = 0
100
+
101
+
102
+ def run_fixed_baseline(num_episodes: int = 10) -> tuple[list[float], dict]:
103
+ """
104
+ Evaluate the fixed-timing signal for *num_episodes* episodes.
105
+
106
+ Returns:
107
+ (episode_rewards, summary_dict)
108
+ """
109
+ logger.info("=" * 60)
110
+ logger.info(f"FIXED-SIGNAL BASELINE ({num_episodes} episodes)")
111
+ logger.info("=" * 60)
112
+
113
+ agent = FixedSignalAgent(switch_interval=30)
114
+ env = make_env()
115
+ rewards: list[float] = []
116
+ info: dict = {}
117
+
118
+ for ep in range(1, num_episodes + 1):
119
+ state, _ = env.reset()
120
+ agent.reset()
121
+ ep_reward = 0.0
122
+ done = False
123
+
124
+ while not done:
125
+ action = agent.select_action(state)
126
+ state, reward, terminated, truncated, info = env.step(action)
127
+ done = terminated or truncated
128
+ ep_reward += reward
129
+
130
+ rewards.append(ep_reward)
131
+ logger.info(f" Episode {ep:3d}/{num_episodes} reward={ep_reward:.2f}")
132
+
133
+ mean_r = float(np.mean(rewards))
134
+ logger.info(f"Baseline mean reward: {mean_r:.2f}")
135
+
136
+ return rewards, {
137
+ "mean_reward": mean_r,
138
+ "std_reward": float(np.std(rewards)),
139
+ "best_reward": float(np.max(rewards)),
140
+ "mean_waiting_time": float(info.get("average_waiting_time", 0)),
141
+ "mean_queue_length": float(info.get("total_queue_length", 0)),
142
+ }
143
+
144
+
145
+ # ═══════════════════════════════════════════════════════════════════════════════
146
+ # Training mode
147
+ # ═══════════════════════════════════════════════════════════════════════════════
148
+
149
+ def run_training(agent_type: str, num_episodes: int):
150
+ """
151
+ Train the specified agent and save the best model.
152
+
153
+ Args:
154
+ agent_type: "q_learning" or "dqn".
155
+ num_episodes: Number of training episodes.
156
+ """
157
+ logger.info("=" * 60)
158
+ logger.info(f"TRAINING agent={agent_type} episodes={num_episodes}")
159
+ logger.info("=" * 60)
160
+
161
+ env = make_env()
162
+
163
+ if agent_type == "q_learning":
164
+ cfg.AGENT_TYPE = "q_learning"
165
+ agent = make_q_learning_agent()
166
+ elif agent_type == "dqn":
167
+ cfg.AGENT_TYPE = "dqn"
168
+ agent = make_dqn_agent()
169
+ else:
170
+ logger.error(f"Unknown agent type: {agent_type!r}")
171
+ sys.exit(1)
172
+
173
+ trainer = Trainer(env, agent, cfg)
174
+ trainer.train(num_episodes)
175
+
176
+ logger.info(f"Training complete. Best reward: {trainer.best_reward:.2f}")
177
+ return trainer
178
+
179
+
180
+ # ═══════════════════════════════════════════════════════════════════════════════
181
+ # Evaluation mode
182
+ # ═══════════════════════════════════════════════════════════════════════════════
183
+
184
+ def run_evaluation(agent_type: str, model_path: str, num_episodes: int = 10) -> dict:
185
+ """
186
+ Load a saved model and evaluate it.
187
+
188
+ Args:
189
+ agent_type: "q_learning" or "dqn".
190
+ model_path: Path to saved model file.
191
+ num_episodes: Evaluation episodes.
192
+
193
+ Returns:
194
+ Evaluation results dictionary.
195
+ """
196
+ logger.info("=" * 60)
197
+ logger.info(f"EVALUATION agent={agent_type} model={model_path}")
198
+ logger.info("=" * 60)
199
+
200
+ env = make_env()
201
+
202
+ if agent_type == "q_learning":
203
+ cfg.AGENT_TYPE = "q_learning"
204
+ agent = make_q_learning_agent()
205
+ else:
206
+ cfg.AGENT_TYPE = "dqn"
207
+ agent = make_dqn_agent()
208
+
209
+ agent.load(model_path)
210
+ evaluator = Evaluator(env, agent, cfg)
211
+ results = evaluator.evaluate(num_episodes)
212
+
213
+ logger.info("Evaluation results:")
214
+ for k, v in results.items():
215
+ logger.info(f" {k}: {v:.4f}" if isinstance(v, float) else f" {k}: {v}")
216
+
217
+ return results
218
+
219
+
220
+ # ═══════════════════════════════════════════════════════════════════════════════
221
+ # Automated pipeline
222
+ # ═══════════════════════════════════════════════════════════════════════════════
223
+
224
+ def run_auto_pipeline():
225
+ """
226
+ Full automated pipeline:
227
+ 1. Fixed-signal baseline
228
+ 2. Q-Learning training (50 episodes)
229
+ 3. DQN training (150 episodes)
230
+ 4. Evaluation of all methods
231
+ 5. Comparison plots
232
+ """
233
+ logger.info("╔" + "═" * 58 + "╗")
234
+ logger.info("║ AUTOMATED RL TRAFFIC SIGNAL CONTROL PIPELINE ║")
235
+ logger.info("╚" + "═" * 58 + "╝")
236
+
237
+ summary: dict[str, dict] = {}
238
+
239
+ # ── 1. Fixed-signal baseline ──────────────────────────────────────
240
+ baseline_rewards, baseline_results = run_fixed_baseline(num_episodes=10)
241
+ summary["Fixed Signal"] = baseline_results
242
+
243
+ # ── 2. Q-Learning ────────────────────────────────────────────────
244
+ ql_trainer = run_training("q_learning", num_episodes=50)
245
+ summary["Q-Learning"] = {
246
+ "mean_reward": ql_trainer.metrics.get_mean("episode_reward"),
247
+ "best_reward": ql_trainer.best_reward,
248
+ "std_reward": ql_trainer.metrics.get_std("episode_reward"),
249
+ }
250
+
251
+ # ── 3. DQN ───────────────────────────────────────────────────────
252
+ if DQN_AVAILABLE:
253
+ dqn_trainer = run_training("dqn", num_episodes=150)
254
+ summary["DQN"] = {
255
+ "mean_reward": dqn_trainer.metrics.get_mean("episode_reward"),
256
+ "best_reward": dqn_trainer.best_reward,
257
+ "std_reward": dqn_trainer.metrics.get_std("episode_reward"),
258
+ }
259
+ else:
260
+ logger.warning("DQN skipped (PyTorch not available).")
261
+
262
+ # ── 4. Print comparison table ─────────────────────────────────────
263
+ _print_comparison_table(summary)
264
+
265
+ # ── 5. Plots ──────────────────────────────────────────────────────
266
+ _generate_comparison_plots(summary)
267
+
268
+ logger.info("Pipeline complete.")
269
+
270
+
271
+ def _print_comparison_table(summary: dict):
272
+ """Print a neat comparison table to stdout."""
273
+ print("\n")
274
+ print("=" * 60)
275
+ print(f"{'Method':<18} {'Mean Reward':>14} {'Best Reward':>14}")
276
+ print("-" * 60)
277
+ baseline_mean = summary.get("Fixed Signal", {}).get("mean_reward", 0)
278
+
279
+ for method, res in summary.items():
280
+ mean_r = res.get("mean_reward", 0)
281
+ best_r = res.get("best_reward", 0)
282
+ delta = mean_r - baseline_mean if method != "Fixed Signal" else 0
283
+ delta_str = f" ({delta:+.2f})" if method != "Fixed Signal" else ""
284
+ print(f"{method:<18} {mean_r:>14.2f} {best_r:>14.2f}{delta_str}")
285
+
286
+ print("=" * 60)
287
+ print()
288
+
289
+
290
+ def _generate_comparison_plots(summary: dict):
291
+ """Save bar-chart comparison of mean rewards."""
292
+ scores = {m: r.get("mean_reward", 0) for m, r in summary.items()}
293
+ save_path = cfg.RESULTS_DIR / "plots" / "comparison_bar.png"
294
+ plot_bar_comparison(
295
+ scores,
296
+ title="Mean Reward by Method (higher = better)",
297
+ ylabel="Mean Reward",
298
+ save_path=save_path,
299
+ )
300
+
301
+
302
+ # ═══════════════════════════════════════════════════════════════════════════════
303
+ # CLI
304
+ # ═══════════════════════════════════════════════════════════════════════════════
305
+
306
+ def _build_parser() -> argparse.ArgumentParser:
307
+ p = argparse.ArgumentParser(
308
+ description="RL Traffic Signal Control",
309
+ formatter_class=argparse.RawDescriptionHelpFormatter,
310
+ epilog="""
311
+ Examples:
312
+ python main.py --auto
313
+ python main.py --mode train --agent q_learning --episodes 50
314
+ python main.py --mode train --agent dqn --episodes 150
315
+ python main.py --mode eval --agent q_learning --model-path models/q_learning_best.pth
316
+ python main.py --mode fixed
317
+ """,
318
+ )
319
+
320
+ p.add_argument(
321
+ "--auto",
322
+ action="store_true",
323
+ help="Run the full automated pipeline (recommended)",
324
+ )
325
+ p.add_argument(
326
+ "--mode",
327
+ choices=["train", "eval", "fixed"],
328
+ default="train",
329
+ help="Mode to run (ignored when --auto is set)",
330
+ )
331
+ p.add_argument(
332
+ "--agent",
333
+ choices=["q_learning", "dqn"],
334
+ default="q_learning",
335
+ help="Agent type",
336
+ )
337
+ p.add_argument(
338
+ "--episodes",
339
+ type=int,
340
+ default=50,
341
+ help="Number of episodes",
342
+ )
343
+ p.add_argument(
344
+ "--model-path",
345
+ type=str,
346
+ default=None,
347
+ help="Path to saved model file (required for --mode eval)",
348
+ )
349
+
350
+ return p
351
+
352
+
353
+ def main():
354
+ parser = _build_parser()
355
+ args = parser.parse_args()
356
+
357
+ if args.auto:
358
+ run_auto_pipeline()
359
+ return
360
+
361
+ if args.mode == "fixed":
362
+ run_fixed_baseline(num_episodes=args.episodes)
363
+
364
+ elif args.mode == "train":
365
+ run_training(args.agent, args.episodes)
366
+
367
+ elif args.mode == "eval":
368
+ if args.model_path is None:
369
+ parser.error("--model-path is required for --mode eval")
370
+ run_evaluation(args.agent, args.model_path, num_episodes=10)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ main()
models/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Saved model files (.pth for DQN, .npy for Q-Learning) are stored here.
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core (required)
2
+ numpy>=1.24.0,<2.0.0
3
+ gymnasium>=0.29.0,<1.0.0
4
+ matplotlib>=3.7.0,<4.0.0
5
+
6
+ # Deep learning — PyTorch (for DQN + GPU support)
7
+ # CPU-only wheels (smaller):
8
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
9
+ # GPU wheels (CUDA 12.1):
10
+ # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
11
+ torch>=2.0.0
12
+
13
+ # Optional — progress bars
14
+ tqdm>=4.65.0
15
+
16
+ # Web (for deployment)
17
+ fastapi>=0.100.0
18
+ uvicorn>=0.23.0
19
+ pydantic>=2.0.0
results/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Training logs, plots, checkpoints and metrics.json are written here.
test_env.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ from pathlib import Path
4
+
5
+ # Add project root to path
6
+ ROOT_DIR = Path(__file__).parent
7
+ sys.path.append(str(ROOT_DIR))
8
+
9
+ import config as cfg
10
+ from environment import TrafficEnvironment
11
+ from agent import DQNAgent
12
+
13
+ def test_model():
14
+ print("Initializing environment...")
15
+ env = TrafficEnvironment(cfg)
16
+
17
+ print("Initializing Agent...")
18
+ agent = DQNAgent(cfg.STATE_SIZE, cfg.ACTION_SIZE, cfg.DQN_CONFIG)
19
+
20
+ model_path = ROOT_DIR / "models" / "dqn_best.pth"
21
+ if model_path.exists():
22
+ agent.load(str(model_path))
23
+ print("Model loaded successfully.")
24
+ else:
25
+ print("ERROR: Model not found at", model_path)
26
+ return
27
+
28
+ state, _ = env.reset()
29
+
30
+ print("\n--- Simulation Test ---")
31
+ action_1_count = 0
32
+ phase_changes = 0
33
+ last_phase = env.current_phase
34
+
35
+ for i in range(200):
36
+ action = agent.select_action(state, training=False)
37
+ if action == 1:
38
+ action_1_count += 1
39
+
40
+ next_state, reward, _, _, info = env.step(action)
41
+
42
+ if env.current_phase != last_phase:
43
+ phase_changes += 1
44
+ print(f"Step {i}: Phase changed from {last_phase} to {env.current_phase}")
45
+ last_phase = env.current_phase
46
+
47
+ state = next_state
48
+
49
+ print("\n--- Test Results ---")
50
+ print(f"Total Steps: 200")
51
+ print(f"Agent chose Switch (1): {action_1_count} times")
52
+ print(f"Actual Phase Changes: {phase_changes}")
53
+ print(f"Final Queues: {env.queue_lengths}")
54
+
55
+ if __name__ == '__main__':
56
+ test_model()
training/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training package.
3
+ """
4
+
5
+ from .trainer import Trainer
6
+ from .evaluator import Evaluator
7
+
8
+ __all__ = ["Trainer", "Evaluator"]
training/evaluator.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluator — assesses a trained agent over multiple episodes without exploration.
3
+
4
+ Produces summary statistics:
5
+ mean/std reward, mean waiting time, mean queue length, mean throughput.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ try:
13
+ from tqdm import tqdm as _tqdm
14
+ _TQDM = True
15
+ except ImportError:
16
+ _TQDM = False
17
+
18
+ from utils.logger import setup_logger
19
+
20
+
21
+ class Evaluator:
22
+ """
23
+ Runs a trained agent in greedy (no exploration) mode.
24
+
25
+ Args:
26
+ env: Environment instance (reset between episodes).
27
+ agent: Trained agent (select_action called with training=False).
28
+ config: Project config module.
29
+ """
30
+
31
+ def __init__(self, env, agent, config):
32
+ self.env = env
33
+ self.agent = agent
34
+ self.config = config
35
+ self.logger = setup_logger("evaluator")
36
+
37
+ def evaluate(self, num_episodes: int, render: bool = False) -> dict:
38
+ """
39
+ Evaluate the agent for *num_episodes* episodes.
40
+
41
+ Args:
42
+ num_episodes: Number of evaluation episodes.
43
+ render: Call env.render() at each step if True.
44
+
45
+ Returns:
46
+ Dictionary with mean/std reward and mean performance metrics.
47
+ """
48
+ self.logger.info(f"Evaluating for {num_episodes} episodes …")
49
+
50
+ rewards, waits, queues, thrus = [], [], [], []
51
+
52
+ iterator = (
53
+ _tqdm(range(num_episodes), desc="Eval", unit="ep")
54
+ if _TQDM
55
+ else range(num_episodes)
56
+ )
57
+
58
+ for _ in iterator:
59
+ ep_reward, info = self._run_episode(render=render)
60
+ rewards.append(ep_reward)
61
+ waits.append(info.get("average_waiting_time", 0.0))
62
+ queues.append(info.get("total_queue_length", 0.0))
63
+ thrus.append(info.get("vehicles_passed", 0))
64
+
65
+ results = {
66
+ "mean_reward": float(np.mean(rewards)),
67
+ "std_reward": float(np.std(rewards)),
68
+ "best_reward": float(np.max(rewards)),
69
+ "mean_waiting_time": float(np.mean(waits)),
70
+ "mean_queue_length": float(np.mean(queues)),
71
+ "mean_throughput": float(np.mean(thrus)),
72
+ }
73
+
74
+ self.logger.info(f" mean reward : {results['mean_reward']:.2f}")
75
+ self.logger.info(f" best reward : {results['best_reward']:.2f}")
76
+ self.logger.info(f" mean wait : {results['mean_waiting_time']:.2f}")
77
+
78
+ return results
79
+
80
+ # ------------------------------------------------------------------
81
+ # Internal
82
+ # ------------------------------------------------------------------
83
+
84
+ def _run_episode(self, render: bool = False) -> tuple[float, dict]:
85
+ state, _ = self.env.reset()
86
+ ep_reward = 0.0
87
+ done = False
88
+ info: dict = {}
89
+
90
+ while not done:
91
+ action = self.agent.select_action(state, training=False)
92
+ next_state, reward, terminated, truncated, info = self.env.step(action)
93
+ done = terminated or truncated
94
+ if render:
95
+ self.env.render()
96
+ state = next_state
97
+ ep_reward += reward
98
+
99
+ return ep_reward, info
training/trainer.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Trainer — manages the training loop for any BaseAgent.
3
+
4
+ Features:
5
+ • Per-episode logging (episode, reward, waiting time, queue, throughput)
6
+ • Automatic best-model saving
7
+ • Periodic checkpoints
8
+ • Early stopping
9
+ • DQN target-network updates
10
+ • Graceful error recovery (episode-level try/except)
11
+ • Optional tqdm progress bar
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import traceback
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ try:
23
+ from tqdm import tqdm
24
+ _TQDM = True
25
+ except ImportError:
26
+ _TQDM = False
27
+
28
+ from utils.logger import setup_logger
29
+ from utils.metrics import MetricsTracker
30
+
31
+
32
+ class Trainer:
33
+ """
34
+ Orchestrates the RL training loop.
35
+
36
+ Args:
37
+ env: A Gymnasium-compatible environment.
38
+ agent: Any agent that inherits :class:`BaseAgent`.
39
+ config: The project config module.
40
+ """
41
+
42
+ def __init__(self, env, agent, config):
43
+ self.env = env
44
+ self.agent = agent
45
+ self.config = config
46
+
47
+ # Directories
48
+ config.RESULTS_DIR.mkdir(parents=True, exist_ok=True)
49
+ config.MODELS_DIR.mkdir(parents=True, exist_ok=True)
50
+ (config.RESULTS_DIR / "logs").mkdir(exist_ok=True)
51
+ (config.RESULTS_DIR / "plots").mkdir(exist_ok=True)
52
+ (config.RESULTS_DIR / "checkpoints").mkdir(exist_ok=True)
53
+
54
+ # Logger
55
+ log_file = config.RESULTS_DIR / "logs" / "training.log"
56
+ self.logger = setup_logger("trainer", log_file=str(log_file))
57
+
58
+ # Metrics
59
+ self.metrics = MetricsTracker()
60
+
61
+ # State
62
+ self.best_reward: float = -np.inf
63
+ self.episodes_without_improvement: int = 0
64
+ self.current_episode: int = 0
65
+ self.total_steps: int = 0
66
+
67
+ self.logger.info("=" * 70)
68
+ self.logger.info("TRAINER READY")
69
+ self.logger.info(f" Agent type : {config.AGENT_TYPE}")
70
+ self.logger.info(f" Results dir: {config.RESULTS_DIR}")
71
+ self.logger.info("=" * 70)
72
+
73
+ # ------------------------------------------------------------------
74
+ # Public
75
+ # ------------------------------------------------------------------
76
+
77
+ def train(self, num_episodes: int):
78
+ """
79
+ Run the training loop for *num_episodes* episodes.
80
+
81
+ Args:
82
+ num_episodes: Number of episodes to train.
83
+ """
84
+ self.logger.info(f"Starting training — {num_episodes} episodes")
85
+
86
+ iterator = (
87
+ tqdm(range(1, num_episodes + 1), desc="Training", unit="ep")
88
+ if _TQDM
89
+ else range(1, num_episodes + 1)
90
+ )
91
+
92
+ try:
93
+ for episode in iterator:
94
+ self.current_episode = episode
95
+
96
+ try:
97
+ ep_reward, ep_info = self._run_episode(training=True)
98
+ except KeyboardInterrupt:
99
+ self.logger.info("Training interrupted by user.")
100
+ self._save_checkpoint(episode, emergency=True)
101
+ raise
102
+ except Exception as exc:
103
+ self.logger.error(f"Episode {episode} error: {exc}")
104
+ self.logger.debug(traceback.format_exc())
105
+ continue
106
+
107
+ # Record metrics
108
+ self.metrics.add("episode_reward", ep_reward)
109
+ self.metrics.add("average_waiting_time",
110
+ ep_info.get("average_waiting_time", 0.0))
111
+ self.metrics.add("average_queue_length",
112
+ ep_info.get("total_queue_length", 0.0))
113
+ self.metrics.add("throughput",
114
+ ep_info.get("vehicles_passed", 0))
115
+
116
+ # Per-episode log
117
+ self.logger.info(
118
+ f"Ep {episode:4d}/{num_episodes} "
119
+ f"reward={ep_reward:8.2f} "
120
+ f"wait={ep_info.get('average_waiting_time', 0):7.1f} "
121
+ f"queue={ep_info.get('total_queue_length', 0):6.1f} "
122
+ f"thru={ep_info.get('vehicles_passed', 0):4d}"
123
+ )
124
+
125
+ # DQN: sync target network
126
+ if hasattr(self.agent, "update_target_network"):
127
+ freq = self.config.DQN_CONFIG.get("target_update", 10)
128
+ if episode % freq == 0:
129
+ self.agent.update_target_network()
130
+
131
+ # Save best model
132
+ if ep_reward > self.best_reward:
133
+ self.best_reward = ep_reward
134
+ self.episodes_without_improvement = 0
135
+ self._save_best_model(episode, ep_reward)
136
+ else:
137
+ self.episodes_without_improvement += 1
138
+
139
+ # Periodic checkpoint
140
+ if episode % self.config.SAVE_FREQUENCY == 0:
141
+ self._save_checkpoint(episode)
142
+
143
+ # Periodic summary
144
+ if episode % 100 == 0:
145
+ self._log_summary(episode, num_episodes)
146
+
147
+ # Early stopping
148
+ if self.episodes_without_improvement >= self.config.EARLY_STOPPING_PATIENCE:
149
+ self.logger.info(
150
+ f"Early stopping at episode {episode} "
151
+ f"(no improvement for "
152
+ f"{self.config.EARLY_STOPPING_PATIENCE} episodes)."
153
+ )
154
+ break
155
+
156
+ except KeyboardInterrupt:
157
+ self.logger.info("Exiting gracefully.")
158
+ sys.exit(0)
159
+
160
+ self.logger.info("=" * 70)
161
+ self.logger.info("TRAINING COMPLETE")
162
+ self._log_final_summary()
163
+ self._save_metrics()
164
+ self._plot_results()
165
+
166
+ # ------------------------------------------------------------------
167
+ # Internal
168
+ # ------------------------------------------------------------------
169
+
170
+ def _run_episode(self, training: bool = True) -> tuple[float, dict]:
171
+ """Execute one full episode."""
172
+ state, _ = self.env.reset()
173
+ ep_reward = 0.0
174
+ done = False
175
+ info: dict = {}
176
+ max_steps = self.config.EPISODE_LENGTH * 2
177
+
178
+ steps = 0
179
+ while not done and steps < max_steps:
180
+ action = self.agent.select_action(state, training=training)
181
+ next_state, reward, terminated, truncated, info = self.env.step(action)
182
+ done = terminated or truncated
183
+
184
+ if training:
185
+ loss = self.agent.train_step(state, action, reward, next_state, done)
186
+ if loss is not None:
187
+ self.metrics.add("loss", float(loss))
188
+
189
+ state = next_state
190
+ ep_reward += reward
191
+ steps += 1
192
+ self.total_steps += 1
193
+
194
+ return ep_reward, info
195
+
196
+ def _save_best_model(self, episode: int, reward: float):
197
+ path = self.config.MODELS_DIR / f"{self.config.AGENT_TYPE}_best.pth"
198
+ try:
199
+ self.agent.save(str(path))
200
+ self.logger.info(
201
+ f"[OK] Best model saved reward={reward:.2f} (episode {episode})"
202
+ )
203
+ except Exception as exc:
204
+ self.logger.error(f"[FAIL] Could not save best model: {exc}")
205
+
206
+ def _save_checkpoint(self, episode: int, emergency: bool = False):
207
+ tag = "emergency" if emergency else f"ep{episode}"
208
+ path = (
209
+ self.config.RESULTS_DIR
210
+ / "checkpoints"
211
+ / f"{self.config.AGENT_TYPE}_{tag}.pth"
212
+ )
213
+ try:
214
+ self.agent.save(str(path))
215
+ self.logger.info(f"[OK] Checkpoint saved -> {path}")
216
+ except Exception as exc:
217
+ self.logger.error(f"[FAIL] Could not save checkpoint: {exc}")
218
+
219
+ def _log_summary(self, episode: int, total: int):
220
+ n = min(100, episode)
221
+ self.logger.info("-" * 70)
222
+ self.logger.info(f"Summary ep {episode}/{total}")
223
+ self.logger.info(
224
+ f" Avg reward (last {n}): "
225
+ f"{self.metrics.get_mean('episode_reward', last_n=n):8.2f}"
226
+ )
227
+ self.logger.info(
228
+ f" Avg wait (last {n}): "
229
+ f"{self.metrics.get_mean('average_waiting_time', last_n=n):8.2f}"
230
+ )
231
+ self.logger.info(f" Best reward so far : {self.best_reward:8.2f}")
232
+ self.logger.info("-" * 70)
233
+
234
+ def _log_final_summary(self):
235
+ all_r = self.metrics.get("episode_reward")
236
+ if not all_r:
237
+ return
238
+ self.logger.info("FINAL STATISTICS")
239
+ self.logger.info(f" Total episodes : {len(all_r)}")
240
+ self.logger.info(f" Best reward : {self.best_reward:.2f}")
241
+ self.logger.info(f" Mean reward : {np.mean(all_r):.2f}")
242
+ self.logger.info(f" Std reward : {np.std(all_r):.2f}")
243
+
244
+ def _save_metrics(self):
245
+ path = self.config.RESULTS_DIR / "metrics.json"
246
+ try:
247
+ self.metrics.save(path)
248
+ self.logger.info(f"[OK] Metrics saved -> {path}")
249
+ except Exception as exc:
250
+ self.logger.warning(f"Could not save metrics: {exc}")
251
+
252
+ def _plot_results(self):
253
+ try:
254
+ from utils.visualizer import plot_training_curves
255
+ save = self.config.RESULTS_DIR / "plots" / f"{self.config.AGENT_TYPE}_training.png"
256
+ plot_training_curves(self.metrics, save_path=save)
257
+ except Exception as exc:
258
+ self.logger.warning(f"Could not plot results: {exc}")
utils/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utils package.
3
+ """
4
+
5
+ from .logger import setup_logger
6
+ from .metrics import MetricsTracker
7
+ from .visualizer import plot_training_curves, plot_comparison, plot_bar_comparison
8
+
9
+ __all__ = [
10
+ "setup_logger",
11
+ "MetricsTracker",
12
+ "plot_training_curves",
13
+ "plot_comparison",
14
+ "plot_bar_comparison",
15
+ ]
utils/logger.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Logging utilities — creates consistent console + file loggers.
3
+ """
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def setup_logger(
11
+ name: str,
12
+ log_file: str | None = None,
13
+ level: int = logging.INFO,
14
+ ) -> logging.Logger:
15
+ """
16
+ Create (or retrieve) a named logger with console and optional file output.
17
+
18
+ Args:
19
+ name: Logger name (used to namespace messages).
20
+ log_file: If given, also write to this path.
21
+ level: Logging threshold (default INFO).
22
+
23
+ Returns:
24
+ Configured :class:`logging.Logger` instance.
25
+ """
26
+ logger = logging.getLogger(name)
27
+ logger.setLevel(level)
28
+
29
+ # Avoid duplicate handlers when called multiple times
30
+ if logger.handlers:
31
+ return logger
32
+
33
+ fmt = logging.Formatter(
34
+ "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
35
+ datefmt="%Y-%m-%d %H:%M:%S",
36
+ )
37
+
38
+ # Console handler
39
+ ch = logging.StreamHandler(sys.stdout)
40
+ ch.setLevel(level)
41
+ ch.setFormatter(fmt)
42
+ logger.addHandler(ch)
43
+
44
+ # File handler (optional)
45
+ if log_file:
46
+ log_path = Path(log_file)
47
+ log_path.parent.mkdir(parents=True, exist_ok=True)
48
+ fh = logging.FileHandler(log_file, encoding="utf-8")
49
+ fh.setLevel(level)
50
+ fh.setFormatter(fmt)
51
+ logger.addHandler(fh)
52
+
53
+ return logger
utils/metrics.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Metrics tracking — lightweight replacement for TensorBoard / W&B.
3
+
4
+ Stores lists of scalar values keyed by metric name, and provides
5
+ summary statistics and JSON serialisation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+
17
+ class MetricsTracker:
18
+ """
19
+ Accumulates scalar training metrics across episodes.
20
+
21
+ Usage::
22
+
23
+ tracker = MetricsTracker()
24
+ tracker.add("episode_reward", -920.3)
25
+ tracker.get_mean("episode_reward", last_n=100)
26
+ tracker.save("results/metrics.json")
27
+ """
28
+
29
+ def __init__(self):
30
+ self._data: dict[str, list] = defaultdict(list)
31
+
32
+ # ------------------------------------------------------------------
33
+ # Data operations
34
+ # ------------------------------------------------------------------
35
+
36
+ def add(self, name: str, value):
37
+ """Append *value* to the metric called *name*."""
38
+ self._data[name].append(value)
39
+
40
+ def get(self, name: str) -> list:
41
+ """Return all recorded values for *name* (empty list if absent)."""
42
+ return list(self._data.get(name, []))
43
+
44
+ def has(self, name: str) -> bool:
45
+ """True if at least one value for *name* has been recorded."""
46
+ return name in self._data and len(self._data[name]) > 0
47
+
48
+ def get_last(self, name: str, n: int = 1) -> list:
49
+ vals = self.get(name)
50
+ return vals[-n:]
51
+
52
+ def get_mean(self, name: str, last_n: int | None = None) -> float:
53
+ vals = self.get(name)
54
+ if not vals:
55
+ return 0.0
56
+ if last_n:
57
+ vals = vals[-last_n:]
58
+ return float(np.mean(vals))
59
+
60
+ def get_std(self, name: str, last_n: int | None = None) -> float:
61
+ vals = self.get(name)
62
+ if not vals:
63
+ return 0.0
64
+ if last_n:
65
+ vals = vals[-last_n:]
66
+ return float(np.std(vals))
67
+
68
+ def summary(self, name: str) -> dict:
69
+ vals = self.get(name)
70
+ if not vals:
71
+ return {}
72
+ return {
73
+ "count": len(vals),
74
+ "mean": float(np.mean(vals)),
75
+ "std": float(np.std(vals)),
76
+ "min": float(np.min(vals)),
77
+ "max": float(np.max(vals)),
78
+ }
79
+
80
+ # ------------------------------------------------------------------
81
+ # Persistence
82
+ # ------------------------------------------------------------------
83
+
84
+ def save(self, filepath: str | Path):
85
+ """Serialise to JSON."""
86
+ filepath = Path(filepath)
87
+ filepath.parent.mkdir(parents=True, exist_ok=True)
88
+ with open(filepath, "w") as fh:
89
+ json.dump({k: list(v) for k, v in self._data.items()}, fh, indent=2)
90
+
91
+ def load(self, filepath: str | Path):
92
+ """Restore from a previously saved JSON file."""
93
+ with open(filepath) as fh:
94
+ raw = json.load(fh)
95
+ self._data = defaultdict(list, raw)
96
+
97
+ def reset(self):
98
+ """Clear all accumulated metrics."""
99
+ self._data.clear()
100
+
101
+ # ------------------------------------------------------------------
102
+ # Dunder helpers
103
+ # ------------------------------------------------------------------
104
+
105
+ def __repr__(self) -> str:
106
+ lines = []
107
+ for name, vals in self._data.items():
108
+ if vals:
109
+ lines.append(
110
+ f" {name}: mean={np.mean(vals):.2f} "
111
+ f"std={np.std(vals):.2f} n={len(vals)}"
112
+ )
113
+ return "MetricsTracker(\n" + "\n".join(lines) + "\n)"
utils/visualizer.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Visualisation utilities — training curves and comparison plots.
3
+
4
+ Uses only Matplotlib (no Seaborn / Plotly dependency).
5
+ All plots are saved to disk (non-interactive backend).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ try:
15
+ import matplotlib
16
+ matplotlib.use("Agg") # Non-interactive backend (safe on servers)
17
+ import matplotlib.pyplot as plt
18
+ _MPL_OK = True
19
+ except ImportError:
20
+ _MPL_OK = False
21
+ plt = None # type: ignore
22
+
23
+
24
+ # ── Helper ────────────────────────────────────────────────────────────────────
25
+
26
+ def _check_mpl():
27
+ if not _MPL_OK:
28
+ raise ImportError(
29
+ "matplotlib is required for plotting.\n"
30
+ "Install with: pip install matplotlib"
31
+ )
32
+
33
+
34
+ def _moving_average(values: list, window: int) -> list:
35
+ """Simple unweighted moving average."""
36
+ result = []
37
+ for i in range(len(values)):
38
+ start = max(0, i - window + 1)
39
+ result.append(float(np.mean(values[start : i + 1])))
40
+ return result
41
+
42
+
43
+ # ── Public functions ──────────────────────────────────────────────────────────
44
+
45
+ def plot_training_curves(metrics, save_path: str | Path | None = None) -> bool:
46
+ """
47
+ Plot four training metrics in a 2×2 grid and save to *save_path*.
48
+
49
+ Args:
50
+ metrics: A :class:`MetricsTracker` instance.
51
+ save_path: Destination PNG path. Shown interactively if None.
52
+
53
+ Returns:
54
+ True on success, False on failure.
55
+ """
56
+ try:
57
+ _check_mpl()
58
+
59
+ panel_cfg = [
60
+ ("episode_reward", "Episode Reward", "blue", "Reward"),
61
+ ("average_waiting_time", "Avg Waiting Time", "orange", "Waiting Time (s)"),
62
+ ("average_queue_length", "Avg Queue Length", "red", "Queue Length"),
63
+ ("throughput", "Throughput", "green", "Vehicles Passed"),
64
+ ]
65
+
66
+ has_any = any(metrics.has(k) for k, *_ in panel_cfg)
67
+ if not has_any:
68
+ print("[WARN] No data available for plotting.")
69
+ return False
70
+
71
+ fig, axes = plt.subplots(2, 2, figsize=(15, 10))
72
+ fig.suptitle("Training Progress", fontsize=16, fontweight="bold")
73
+ axes_flat = axes.flatten()
74
+
75
+ for ax, (key, title, colour, ylabel) in zip(axes_flat, panel_cfg):
76
+ ax.set_title(title, fontsize=12, fontweight="bold")
77
+ ax.set_xlabel("Episode", fontsize=10)
78
+ ax.set_ylabel(ylabel, fontsize=10)
79
+ ax.grid(True, alpha=0.3)
80
+
81
+ if not metrics.has(key):
82
+ ax.text(0.5, 0.5, "No data", ha="center", va="center",
83
+ transform=ax.transAxes, color="grey")
84
+ continue
85
+
86
+ vals = metrics.get(key)
87
+ eps = range(1, len(vals) + 1)
88
+ ax.plot(eps, vals, alpha=0.4, color=colour, linewidth=1, label="Raw")
89
+
90
+ if len(vals) >= 10:
91
+ w = min(50, max(10, len(vals) // 10))
92
+ ma = _moving_average(vals, w)
93
+ ax.plot(eps, ma, color=colour, linewidth=2,
94
+ label=f"MA-{w}")
95
+ ax.legend(loc="best", fontsize=8)
96
+
97
+ plt.tight_layout()
98
+
99
+ if save_path:
100
+ save_path = Path(save_path)
101
+ save_path.parent.mkdir(parents=True, exist_ok=True)
102
+ plt.savefig(save_path, dpi=150, bbox_inches="tight", facecolor="white")
103
+ print(f"[OK] Plot saved -> {save_path}")
104
+ else:
105
+ plt.show()
106
+
107
+ plt.close(fig)
108
+ return True
109
+
110
+ except ImportError as exc:
111
+ print(f"[WARN] {exc}")
112
+ return False
113
+ except Exception as exc:
114
+ print(f"[WARN] Plotting error: {exc}")
115
+ try:
116
+ plt.close("all")
117
+ except Exception:
118
+ pass
119
+ return False
120
+
121
+
122
+ def plot_comparison(
123
+ results_dict: dict[str, list],
124
+ metric_name: str,
125
+ save_path: str | Path | None = None,
126
+ ) -> bool:
127
+ """
128
+ Overlay multiple result series on a single axes.
129
+
130
+ Args:
131
+ results_dict: ``{"Method Name": [values, …], …}``
132
+ metric_name: Y-axis label / title suffix.
133
+ save_path: Destination PNG path.
134
+
135
+ Returns:
136
+ True on success.
137
+ """
138
+ try:
139
+ _check_mpl()
140
+
141
+ if not results_dict:
142
+ print("[WARN] No data for comparison plot.")
143
+ return False
144
+
145
+ fig, ax = plt.subplots(figsize=(12, 6))
146
+
147
+ colours = ["blue", "green", "red", "orange", "purple"]
148
+ for i, (name, vals) in enumerate(results_dict.items()):
149
+ if vals:
150
+ ax.plot(range(1, len(vals) + 1), vals,
151
+ label=name, linewidth=2, alpha=0.8,
152
+ color=colours[i % len(colours)])
153
+
154
+ ax.set_xlabel("Episode", fontsize=12)
155
+ ax.set_ylabel(metric_name, fontsize=12)
156
+ ax.set_title(f"{metric_name} - Method Comparison",
157
+ fontsize=14, fontweight="bold")
158
+ ax.legend(loc="best")
159
+ ax.grid(True, alpha=0.3)
160
+
161
+ plt.tight_layout()
162
+
163
+ if save_path:
164
+ save_path = Path(save_path)
165
+ save_path.parent.mkdir(parents=True, exist_ok=True)
166
+ plt.savefig(save_path, dpi=150, bbox_inches="tight", facecolor="white")
167
+ print(f"[OK] Comparison plot saved -> {save_path}")
168
+ else:
169
+ plt.show()
170
+
171
+ plt.close(fig)
172
+ return True
173
+
174
+ except ImportError as exc:
175
+ print(f"[WARN] {exc}")
176
+ return False
177
+ except Exception as exc:
178
+ print(f"[WARN] Comparison plot error: {exc}")
179
+ try:
180
+ plt.close("all")
181
+ except Exception:
182
+ pass
183
+ return False
184
+
185
+
186
+ def plot_bar_comparison(
187
+ method_scores: dict[str, float],
188
+ title: str = "Method Comparison",
189
+ ylabel: str = "Mean Reward",
190
+ save_path: str | Path | None = None,
191
+ ) -> bool:
192
+ """
193
+ Bar chart comparing scalar scores for different methods.
194
+
195
+ Args:
196
+ method_scores: {"Method": score, ...}
197
+ title: Chart title.
198
+ ylabel: Y-axis label.
199
+ save_path: Destination PNG path.
200
+
201
+ Returns:
202
+ True on success.
203
+ """
204
+ try:
205
+ _check_mpl()
206
+
207
+ if not method_scores:
208
+ return False
209
+
210
+ names = list(method_scores.keys())
211
+ scores = [method_scores[n] for n in names]
212
+ colours = ["#4472C4", "#ED7D31", "#A9D18E"]
213
+
214
+ fig, ax = plt.subplots(figsize=(8, 5))
215
+ bars = ax.bar(names, scores,
216
+ color=colours[: len(names)],
217
+ edgecolor="white", linewidth=1.5)
218
+
219
+ # Value labels
220
+ for bar, score in zip(bars, scores):
221
+ ax.text(
222
+ bar.get_x() + bar.get_width() / 2,
223
+ bar.get_height() + (max(scores) - min(scores)) * 0.01,
224
+ f"{score:.2f}",
225
+ ha="center", va="bottom", fontsize=11, fontweight="bold",
226
+ )
227
+
228
+ ax.set_title(title, fontsize=14, fontweight="bold")
229
+ ax.set_ylabel(ylabel, fontsize=12)
230
+ ax.grid(axis="y", alpha=0.3)
231
+ ax.set_ylim(min(scores) * 1.05, max(scores) * 0.95) # Tight y-range
232
+
233
+ plt.tight_layout()
234
+
235
+ if save_path:
236
+ save_path = Path(save_path)
237
+ save_path.parent.mkdir(parents=True, exist_ok=True)
238
+ plt.savefig(save_path, dpi=150, bbox_inches="tight", facecolor="white")
239
+ print(f"[OK] Bar chart saved -> {save_path}")
240
+ else:
241
+ plt.show()
242
+
243
+ plt.close(fig)
244
+ return True
245
+
246
+ except ImportError as exc:
247
+ print(f"[WARN] {exc}")
248
+ return False
249
+ except Exception as exc:
250
+ print(f"[WARN] Bar chart error: {exc}")
251
+ return False