Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- analytics.py +215 -0
- arena.py +389 -0
- dashboard.py +253 -0
- environment.py +90 -19
- inference.py +275 -143
- models.py +18 -0
- pyproject.toml +1 -1
- server/app.py +454 -0
- tasks.py +42 -6
analytics.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Performance Analytics & Episode History
|
| 3 |
+
|
| 4 |
+
Tracks episode metrics over time and provides analysis tools.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import List, Dict, Any, Optional
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class EpisodeMetrics:
|
| 16 |
+
"""Metrics for a single episode."""
|
| 17 |
+
episode_id: str
|
| 18 |
+
task_id: str
|
| 19 |
+
start_time: datetime
|
| 20 |
+
end_time: Optional[datetime] = None
|
| 21 |
+
steps: int = 0
|
| 22 |
+
total_reward: float = 0.0
|
| 23 |
+
total_vehicles: int = 0
|
| 24 |
+
total_emergency: int = 0
|
| 25 |
+
total_waiting_time: float = 0.0
|
| 26 |
+
total_collisions: int = 0
|
| 27 |
+
phase_changes: int = 0
|
| 28 |
+
avg_queue_length: List[float] = field(default_factory=lambda: [0.0, 0.0, 0.0, 0.0])
|
| 29 |
+
rewards_history: List[float] = field(default_factory=list)
|
| 30 |
+
decisions: List[Dict[str, Any]] = field(default_factory=list)
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def duration_seconds(self) -> float:
|
| 34 |
+
if self.end_time:
|
| 35 |
+
return (self.end_time - self.start_time).total_seconds()
|
| 36 |
+
return 0.0
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def throughput_per_step(self) -> float:
|
| 40 |
+
if self.steps > 0:
|
| 41 |
+
return self.total_vehicles / self.steps
|
| 42 |
+
return 0.0
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def avg_reward_per_step(self) -> float:
|
| 46 |
+
if self.steps > 0:
|
| 47 |
+
return self.total_reward / self.steps
|
| 48 |
+
return 0.0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class EpisodeHistory:
|
| 52 |
+
"""Store and analyze episode history."""
|
| 53 |
+
|
| 54 |
+
def __init__(self, max_episodes: int = 100):
|
| 55 |
+
self.episodes: List[EpisodeMetrics] = []
|
| 56 |
+
self.max_episodes = max_episodes
|
| 57 |
+
self._current: Optional[EpisodeMetrics] = None
|
| 58 |
+
|
| 59 |
+
def start_episode(self, episode_id: str, task_id: str) -> EpisodeMetrics:
|
| 60 |
+
"""Start tracking a new episode."""
|
| 61 |
+
episode = EpisodeMetrics(
|
| 62 |
+
episode_id=episode_id,
|
| 63 |
+
task_id=task_id,
|
| 64 |
+
start_time=datetime.now(),
|
| 65 |
+
)
|
| 66 |
+
self._current = episode
|
| 67 |
+
return episode
|
| 68 |
+
|
| 69 |
+
def record_step(
|
| 70 |
+
self,
|
| 71 |
+
step: int,
|
| 72 |
+
reward: float,
|
| 73 |
+
action: Dict[str, Any],
|
| 74 |
+
observation: Dict[str, Any],
|
| 75 |
+
) -> None:
|
| 76 |
+
"""Record a step in the current episode."""
|
| 77 |
+
if self._current:
|
| 78 |
+
self._current.steps = step
|
| 79 |
+
self._current.total_reward += reward
|
| 80 |
+
self._current.rewards_history.append(reward)
|
| 81 |
+
|
| 82 |
+
# Track queue lengths for averaging
|
| 83 |
+
queues = observation.get("queue_lengths", [0, 0, 0, 0])
|
| 84 |
+
for i, q in enumerate(queues):
|
| 85 |
+
self._current.avg_queue_length[i] = (
|
| 86 |
+
self._current.avg_queue_length[i] * (step - 1) + q
|
| 87 |
+
) / step
|
| 88 |
+
|
| 89 |
+
# Record decision
|
| 90 |
+
self._current.decisions.append({
|
| 91 |
+
"step": step,
|
| 92 |
+
"action": action,
|
| 93 |
+
"phase": observation.get("current_phase"),
|
| 94 |
+
"queues": queues,
|
| 95 |
+
"emergency_queues": observation.get("emergency_queue", [0, 0, 0, 0]),
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
def record_state(self, state: Dict[str, Any]) -> None:
|
| 99 |
+
"""Record final state metrics."""
|
| 100 |
+
if self._current:
|
| 101 |
+
self._current.total_vehicles = state.get("total_vehicles_passed", 0)
|
| 102 |
+
self._current.total_emergency = state.get("total_emergency_passed", 0)
|
| 103 |
+
self._current.total_waiting_time = state.get("total_waiting_time", 0.0)
|
| 104 |
+
self._current.total_collisions = state.get("total_collisions", 0)
|
| 105 |
+
self._current.phase_changes = state.get("total_phase_changes", 0)
|
| 106 |
+
|
| 107 |
+
def end_episode(self) -> EpisodeMetrics:
|
| 108 |
+
"""Finalize the current episode."""
|
| 109 |
+
if self._current:
|
| 110 |
+
self._current.end_time = datetime.now()
|
| 111 |
+
self.episodes.append(self._current)
|
| 112 |
+
|
| 113 |
+
# Trim old episodes
|
| 114 |
+
if len(self.episodes) > self.max_episodes:
|
| 115 |
+
self.episodes = self.episodes[-self.max_episodes:]
|
| 116 |
+
|
| 117 |
+
result = self._current
|
| 118 |
+
self._current = None
|
| 119 |
+
return result
|
| 120 |
+
|
| 121 |
+
raise RuntimeError("No active episode to end")
|
| 122 |
+
|
| 123 |
+
def get_summary(self, task_id: Optional[str] = None) -> Dict[str, Any]:
|
| 124 |
+
"""Get summary statistics."""
|
| 125 |
+
episodes = self.episodes
|
| 126 |
+
if task_id:
|
| 127 |
+
episodes = [e for e in episodes if e.task_id == task_id]
|
| 128 |
+
|
| 129 |
+
if not episodes:
|
| 130 |
+
return {"message": "No episodes recorded yet"}
|
| 131 |
+
|
| 132 |
+
total_episodes = len(episodes)
|
| 133 |
+
avg_reward = sum(e.avg_reward_per_step for e in episodes) / total_episodes
|
| 134 |
+
avg_throughput = sum(e.throughput_per_step for e in episodes) / total_episodes
|
| 135 |
+
avg_duration = sum(e.duration_seconds for e in episodes) / total_episodes
|
| 136 |
+
|
| 137 |
+
# Find best episode
|
| 138 |
+
best_idx = max(range(total_episodes), key=lambda i: episodes[i].avg_reward_per_step)
|
| 139 |
+
best = episodes[best_idx]
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"total_episodes": total_episodes,
|
| 143 |
+
"avg_reward_per_step": round(avg_reward, 4),
|
| 144 |
+
"avg_throughput_per_step": round(avg_throughput, 4),
|
| 145 |
+
"avg_duration_seconds": round(avg_duration, 2),
|
| 146 |
+
"best_episode": {
|
| 147 |
+
"episode_id": best.episode_id,
|
| 148 |
+
"task_id": best.task_id,
|
| 149 |
+
"reward_per_step": round(best.avg_reward_per_step, 4),
|
| 150 |
+
"total_reward": round(best.total_reward, 2),
|
| 151 |
+
"steps": best.steps,
|
| 152 |
+
},
|
| 153 |
+
"recent_performance": [
|
| 154 |
+
{
|
| 155 |
+
"episode_id": e.episode_id,
|
| 156 |
+
"task_id": e.task_id,
|
| 157 |
+
"reward_per_step": round(e.avg_reward_per_step, 4),
|
| 158 |
+
"steps": e.steps,
|
| 159 |
+
}
|
| 160 |
+
for e in episodes[-10:]
|
| 161 |
+
],
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
def get_episode_details(self, episode_id: str) -> Optional[Dict[str, Any]]:
|
| 165 |
+
"""Get detailed metrics for a specific episode."""
|
| 166 |
+
for episode in self.episodes:
|
| 167 |
+
if episode.episode_id == episode_id:
|
| 168 |
+
return {
|
| 169 |
+
"episode_id": episode.episode_id,
|
| 170 |
+
"task_id": episode.task_id,
|
| 171 |
+
"start_time": episode.start_time.isoformat(),
|
| 172 |
+
"end_time": episode.end_time.isoformat() if episode.end_time else None,
|
| 173 |
+
"duration_seconds": episode.duration_seconds,
|
| 174 |
+
"steps": episode.steps,
|
| 175 |
+
"total_reward": round(episode.total_reward, 2),
|
| 176 |
+
"total_vehicles": episode.total_vehicles,
|
| 177 |
+
"total_emergency": episode.total_emergency,
|
| 178 |
+
"total_collisions": episode.total_collisions,
|
| 179 |
+
"phase_changes": episode.phase_changes,
|
| 180 |
+
"avg_queue_lengths": [round(q, 2) for q in episode.avg_queue_length],
|
| 181 |
+
"throughput_per_step": round(episode.throughput_per_step, 4),
|
| 182 |
+
"reward_per_step": round(episode.avg_reward_per_step, 4),
|
| 183 |
+
"rewards_history": [round(r, 3) for r in episode.rewards_history],
|
| 184 |
+
"decision_count": len(episode.decisions),
|
| 185 |
+
}
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
def export_to_json(self, filepath: str) -> None:
|
| 189 |
+
"""Export all episodes to JSON file."""
|
| 190 |
+
data = {
|
| 191 |
+
"export_time": datetime.now().isoformat(),
|
| 192 |
+
"total_episodes": len(self.episodes),
|
| 193 |
+
"episodes": [
|
| 194 |
+
{
|
| 195 |
+
"episode_id": e.episode_id,
|
| 196 |
+
"task_id": e.task_id,
|
| 197 |
+
"steps": e.steps,
|
| 198 |
+
"total_reward": e.total_reward,
|
| 199 |
+
"total_vehicles": e.total_vehicles,
|
| 200 |
+
"avg_reward_per_step": e.avg_reward_per_step,
|
| 201 |
+
}
|
| 202 |
+
for e in self.episodes
|
| 203 |
+
],
|
| 204 |
+
}
|
| 205 |
+
with open(filepath, 'w') as f:
|
| 206 |
+
json.dump(data, f, indent=2)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# Global history instance
|
| 210 |
+
_episode_history = EpisodeHistory()
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def get_history() -> EpisodeHistory:
|
| 214 |
+
"""Get the global episode history instance."""
|
| 215 |
+
return _episode_history
|
arena.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Comparative Agent Arena
|
| 3 |
+
|
| 4 |
+
Run multiple agents simultaneously to compare performance.
|
| 5 |
+
Supports:
|
| 6 |
+
- Rule-based agent
|
| 7 |
+
- LLM agent (makes live API calls)
|
| 8 |
+
- Random agent
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
import textwrap
|
| 14 |
+
import time
|
| 15 |
+
import random
|
| 16 |
+
import asyncio
|
| 17 |
+
from typing import Dict, List, Any, Optional
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
|
| 21 |
+
from traffic_control.environment import TrafficControlEnvironment
|
| 22 |
+
from traffic_control.models import TrafficAction, TrafficObservation
|
| 23 |
+
from traffic_control.tasks import grade, GradeResult
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from openai import OpenAI
|
| 28 |
+
_HAS_OPENAI = True
|
| 29 |
+
except ImportError:
|
| 30 |
+
_HAS_OPENAI = False
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class AgentResult:
|
| 35 |
+
"""Result for a single agent run."""
|
| 36 |
+
agent_name: str
|
| 37 |
+
agent_type: str
|
| 38 |
+
task_id: str
|
| 39 |
+
episode_id: str
|
| 40 |
+
steps: int = 0
|
| 41 |
+
total_reward: float = 0.0
|
| 42 |
+
score: float = 0.0
|
| 43 |
+
metrics: Dict[str, Any] = field(default_factory=dict)
|
| 44 |
+
grade_result: Optional[GradeResult] = None
|
| 45 |
+
decision_times: List[float] = field(default_factory=list)
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def avg_decision_time_ms(self) -> float:
|
| 49 |
+
if self.decision_times:
|
| 50 |
+
return sum(self.decision_times) / len(self.decision_times) * 1000
|
| 51 |
+
return 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class RuleBasedAgent:
|
| 55 |
+
"""Rule-based traffic controller (optimized for high scores)."""
|
| 56 |
+
|
| 57 |
+
def __init__(self, name: str = "RuleBased"):
|
| 58 |
+
self.name = name
|
| 59 |
+
|
| 60 |
+
def decide(self, obs: TrafficObservation) -> int:
|
| 61 |
+
"""Return light phase based on rules."""
|
| 62 |
+
em_q = obs.emergency_queue
|
| 63 |
+
em_u = obs.emergency_urgency
|
| 64 |
+
q = obs.queue_lengths
|
| 65 |
+
current = obs.current_phase
|
| 66 |
+
time_in = obs.time_in_phase
|
| 67 |
+
|
| 68 |
+
# Emergency prioritization
|
| 69 |
+
ns_em_urgency = em_u[0] + em_u[1] + em_q[0] * 2 + em_q[1] * 2
|
| 70 |
+
ew_em_urgency = em_u[2] + em_u[3] + em_q[2] * 2 + em_q[3] * 2
|
| 71 |
+
|
| 72 |
+
if ns_em_urgency > 0 or ew_em_urgency > 0:
|
| 73 |
+
return 0 if ns_em_urgency >= ew_em_urgency else 1
|
| 74 |
+
|
| 75 |
+
# Queue-based switching
|
| 76 |
+
ns_total = q[0] + q[1]
|
| 77 |
+
ew_total = q[2] + q[3]
|
| 78 |
+
min_phase_time = min(3 + max(ns_total, ew_total) // 5, 8)
|
| 79 |
+
|
| 80 |
+
if current == 0 and time_in < min_phase_time and ns_total > 0:
|
| 81 |
+
return 0
|
| 82 |
+
if current == 1 and time_in < min_phase_time and ew_total > 0:
|
| 83 |
+
return 1
|
| 84 |
+
|
| 85 |
+
if ns_total >= ew_total + 2:
|
| 86 |
+
return 0
|
| 87 |
+
elif ew_total >= ns_total + 2:
|
| 88 |
+
return 1
|
| 89 |
+
else:
|
| 90 |
+
return current if current in (0, 1) else 0
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class RandomAgent:
|
| 94 |
+
"""Random traffic controller for baseline comparison."""
|
| 95 |
+
|
| 96 |
+
def __init__(self, name: str = "Random", seed: int = 42):
|
| 97 |
+
self.name = name
|
| 98 |
+
self.rng = random.Random(seed)
|
| 99 |
+
|
| 100 |
+
def decide(self, obs: TrafficObservation) -> int:
|
| 101 |
+
"""Return random light phase."""
|
| 102 |
+
return self.rng.choice([0, 1, 2])
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class RoundRobinAgent:
|
| 106 |
+
"""Simple round-robin controller."""
|
| 107 |
+
|
| 108 |
+
def __init__(self, name: str = "RoundRobin", switch_interval: int = 5):
|
| 109 |
+
self.name = name
|
| 110 |
+
self.switch_interval = switch_interval
|
| 111 |
+
self.step_count = 0
|
| 112 |
+
|
| 113 |
+
def decide(self, obs: TrafficObservation) -> int:
|
| 114 |
+
"""Alternate between phases."""
|
| 115 |
+
self.step_count += 1
|
| 116 |
+
phase_index = (self.step_count // self.switch_interval) % 2
|
| 117 |
+
return int(phase_index)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# LLM System Prompt for arena
|
| 121 |
+
LLM_SYSTEM_PROMPT = textwrap.dedent("""
|
| 122 |
+
You are an Autonomous Traffic Control AI managing a 4-way intersection.
|
| 123 |
+
|
| 124 |
+
PHASES:
|
| 125 |
+
0 = North-South Green (N/S vehicles may pass)
|
| 126 |
+
1 = East-West Green (E/W vehicles may pass)
|
| 127 |
+
2 = All Red (no vehicles pass)
|
| 128 |
+
|
| 129 |
+
DECISION RULES (apply in order):
|
| 130 |
+
1. EMERGENCY CHECK: If emergency vehicles are waiting, prioritize them.
|
| 131 |
+
2. MINIMUM PHASE TIME: Stay in current phase at least 3 steps if traffic present.
|
| 132 |
+
3. QUEUE BALANCE: Switch to direction with significantly more traffic.
|
| 133 |
+
|
| 134 |
+
OUTPUT: Reply with exactly one JSON object: {"light_phase": <0, 1, or 2>}
|
| 135 |
+
""")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class LLM_Agent:
|
| 139 |
+
"""LLM-powered agent that makes dynamic API calls."""
|
| 140 |
+
|
| 141 |
+
def __init__(
|
| 142 |
+
self,
|
| 143 |
+
name: str = "LLM-Agent",
|
| 144 |
+
api_base_url: Optional[str] = None,
|
| 145 |
+
api_key: Optional[str] = None,
|
| 146 |
+
model_name: str = "gpt-4.1-mini",
|
| 147 |
+
):
|
| 148 |
+
self.name = name
|
| 149 |
+
self.model_name = model_name
|
| 150 |
+
self.api_calls_made = 0
|
| 151 |
+
|
| 152 |
+
# Initialize OpenAI client if credentials available
|
| 153 |
+
if _HAS_OPENAI and (api_base_url or api_key):
|
| 154 |
+
self.client = OpenAI(
|
| 155 |
+
base_url=api_base_url or "https://api.openai.com/v1",
|
| 156 |
+
api_key=api_key or "dummy-key",
|
| 157 |
+
)
|
| 158 |
+
else:
|
| 159 |
+
self.client = None
|
| 160 |
+
|
| 161 |
+
def _build_prompt(self, obs: TrafficObservation) -> str:
|
| 162 |
+
"""Build the user prompt from observation."""
|
| 163 |
+
return (
|
| 164 |
+
f"Current phase: {obs.current_phase} (0=NS Green, 1=EW Green, 2=All Red)\n"
|
| 165 |
+
f"Time in phase: {obs.time_in_phase} steps\n"
|
| 166 |
+
f"\n"
|
| 167 |
+
f"Queue lengths (N, S, E, W): {obs.queue_lengths}\n"
|
| 168 |
+
f"Emergency queues (N, S, E, W): {obs.emergency_queue}\n"
|
| 169 |
+
f"Emergency urgency (N, S, E, W): {obs.emergency_urgency}\n"
|
| 170 |
+
f"\n"
|
| 171 |
+
f"What light phase should be set? Respond with JSON: {{\"light_phase\": 0, 1, or 2}}"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
def decide(self, obs: TrafficObservation) -> int:
|
| 175 |
+
"""Make LLM API call to get decision."""
|
| 176 |
+
if not self.client:
|
| 177 |
+
# Fallback to rule-based if no client
|
| 178 |
+
return self._rule_fallback(obs)
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
resp = self.client.chat.completions.create(
|
| 182 |
+
model=self.model_name,
|
| 183 |
+
messages=[
|
| 184 |
+
{"role": "system", "content": LLM_SYSTEM_PROMPT},
|
| 185 |
+
{"role": "user", "content": self._build_prompt(obs)},
|
| 186 |
+
],
|
| 187 |
+
temperature=0.0,
|
| 188 |
+
max_tokens=32,
|
| 189 |
+
stream=False,
|
| 190 |
+
)
|
| 191 |
+
self.api_calls_made += 1
|
| 192 |
+
|
| 193 |
+
content = resp.choices[0].message.content.strip()
|
| 194 |
+
|
| 195 |
+
# Parse JSON response
|
| 196 |
+
try:
|
| 197 |
+
data = json.loads(content)
|
| 198 |
+
phase = int(data.get("light_phase", 0))
|
| 199 |
+
return max(0, min(2, phase)) # Clamp to valid range
|
| 200 |
+
except (json.JSONDecodeError, ValueError, KeyError):
|
| 201 |
+
# Fallback if parsing fails
|
| 202 |
+
return self._rule_fallback(obs)
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
# Fallback on API error
|
| 206 |
+
print(f"[LLM Agent] API error: {e}, using fallback")
|
| 207 |
+
return self._rule_fallback(obs)
|
| 208 |
+
|
| 209 |
+
def _rule_fallback(self, obs: TrafficObservation) -> int:
|
| 210 |
+
"""Rule-based fallback when LLM fails."""
|
| 211 |
+
em_q = obs.emergency_queue
|
| 212 |
+
em_u = obs.emergency_urgency
|
| 213 |
+
q = obs.queue_lengths
|
| 214 |
+
current = obs.current_phase
|
| 215 |
+
|
| 216 |
+
# Emergency prioritization
|
| 217 |
+
ns_em = em_u[0] + em_u[1] + em_q[0] + em_q[1]
|
| 218 |
+
ew_em = em_u[2] + em_u[3] + em_q[2] + em_q[3]
|
| 219 |
+
|
| 220 |
+
if ns_em > 0 or ew_em > 0:
|
| 221 |
+
return 0 if ns_em >= ew_em else 1
|
| 222 |
+
|
| 223 |
+
# Queue-based
|
| 224 |
+
ns_total = q[0] + q[1]
|
| 225 |
+
ew_total = q[2] + q[3]
|
| 226 |
+
|
| 227 |
+
if ns_total > ew_total:
|
| 228 |
+
return 0
|
| 229 |
+
elif ew_total > ns_total:
|
| 230 |
+
return 1
|
| 231 |
+
else:
|
| 232 |
+
return current if current in (0, 1) else 0
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class Arena:
|
| 236 |
+
"""Run multiple agents and compare results."""
|
| 237 |
+
|
| 238 |
+
def __init__(self):
|
| 239 |
+
self.results: List[AgentResult] = []
|
| 240 |
+
|
| 241 |
+
# Get LLM credentials from env (for arena LLM agent)
|
| 242 |
+
api_base = os.environ.get("API_BASE_URL")
|
| 243 |
+
api_key = os.environ.get("API_KEY")
|
| 244 |
+
model = os.environ.get("MODEL_NAME", "gpt-4.1-mini")
|
| 245 |
+
|
| 246 |
+
self.agents = {
|
| 247 |
+
"llm": LLM_Agent("Dynamic LLM", api_base, api_key, model),
|
| 248 |
+
"rule_based": RuleBasedAgent("Smart Rule-Based"),
|
| 249 |
+
"random": RandomAgent("Random Baseline"),
|
| 250 |
+
"round_robin": RoundRobinAgent("Round Robin"),
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
async def run_agent(
|
| 254 |
+
self,
|
| 255 |
+
agent_type: str,
|
| 256 |
+
task_id: str,
|
| 257 |
+
max_steps: int = 300,
|
| 258 |
+
seed: int = 42,
|
| 259 |
+
) -> AgentResult:
|
| 260 |
+
"""Run a single agent episode."""
|
| 261 |
+
env = TrafficControlEnvironment(task_id=task_id)
|
| 262 |
+
agent = self.agents.get(agent_type, self.agents["rule_based"])
|
| 263 |
+
|
| 264 |
+
obs = env.reset(seed=seed)
|
| 265 |
+
episode_id = env._episode_id
|
| 266 |
+
|
| 267 |
+
result = AgentResult(
|
| 268 |
+
agent_name=agent.name,
|
| 269 |
+
agent_type=agent_type,
|
| 270 |
+
task_id=task_id,
|
| 271 |
+
episode_id=episode_id,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
for step in range(max_steps):
|
| 275 |
+
import time
|
| 276 |
+
start_time = time.time()
|
| 277 |
+
|
| 278 |
+
action_id = agent.decide(obs)
|
| 279 |
+
action = TrafficAction(light_phase=action_id)
|
| 280 |
+
|
| 281 |
+
decision_time = time.time() - start_time
|
| 282 |
+
result.decision_times.append(decision_time)
|
| 283 |
+
|
| 284 |
+
obs = env.step(action)
|
| 285 |
+
result.steps = step + 1
|
| 286 |
+
result.total_reward += obs.reward or 0.0
|
| 287 |
+
|
| 288 |
+
if obs.done:
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
# Grade the result
|
| 292 |
+
state = env.state
|
| 293 |
+
result.grade_result = grade(
|
| 294 |
+
task_id,
|
| 295 |
+
total_vehicles_passed=state.total_vehicles_passed,
|
| 296 |
+
total_emergency_passed=state.total_emergency_passed,
|
| 297 |
+
total_waiting_time=state.total_waiting_time,
|
| 298 |
+
total_collisions=state.total_collisions,
|
| 299 |
+
total_emergency_delay=state.total_emergency_delay,
|
| 300 |
+
total_phase_changes=state.total_phase_changes,
|
| 301 |
+
step_count=result.steps,
|
| 302 |
+
)
|
| 303 |
+
result.score = result.grade_result.score
|
| 304 |
+
result.metrics = result.grade_result.metrics
|
| 305 |
+
|
| 306 |
+
return result
|
| 307 |
+
|
| 308 |
+
async def run_comparison(
|
| 309 |
+
self,
|
| 310 |
+
task_id: str,
|
| 311 |
+
agents: Optional[List[str]] = None,
|
| 312 |
+
runs_per_agent: int = 1,
|
| 313 |
+
) -> Dict[str, Any]:
|
| 314 |
+
"""Run multiple agents and compare."""
|
| 315 |
+
agents_to_run = agents or list(self.agents.keys())
|
| 316 |
+
all_results = []
|
| 317 |
+
|
| 318 |
+
for agent_type in agents_to_run:
|
| 319 |
+
for run in range(runs_per_agent):
|
| 320 |
+
seed = 42 + run
|
| 321 |
+
result = await self.run_agent(agent_type, task_id, seed=seed)
|
| 322 |
+
all_results.append(result)
|
| 323 |
+
|
| 324 |
+
self.results.extend(all_results)
|
| 325 |
+
|
| 326 |
+
# Aggregate results
|
| 327 |
+
summary = self._aggregate_results(all_results)
|
| 328 |
+
return summary
|
| 329 |
+
|
| 330 |
+
def _aggregate_results(self, results: List[AgentResult]) -> Dict[str, Any]:
|
| 331 |
+
"""Aggregate results by agent type."""
|
| 332 |
+
by_agent: Dict[str, List[AgentResult]] = {}
|
| 333 |
+
for r in results:
|
| 334 |
+
if r.agent_type not in by_agent:
|
| 335 |
+
by_agent[r.agent_type] = []
|
| 336 |
+
by_agent[r.agent_type].append(r)
|
| 337 |
+
|
| 338 |
+
summary = {
|
| 339 |
+
"timestamp": datetime.now().isoformat(),
|
| 340 |
+
"total_runs": len(results),
|
| 341 |
+
"agents": {},
|
| 342 |
+
"winner": None,
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
best_score = -1
|
| 346 |
+
best_agent = None
|
| 347 |
+
|
| 348 |
+
for agent_type, agent_results in by_agent.items():
|
| 349 |
+
avg_score = sum(r.score for r in agent_results) / len(agent_results)
|
| 350 |
+
avg_reward = sum(r.total_reward for r in agent_results) / len(agent_results)
|
| 351 |
+
avg_steps = sum(r.steps for r in agent_results) / len(agent_results)
|
| 352 |
+
avg_time = sum(r.avg_decision_time_ms for r in agent_results) / len(agent_results)
|
| 353 |
+
|
| 354 |
+
summary["agents"][agent_type] = {
|
| 355 |
+
"name": agent_results[0].agent_name,
|
| 356 |
+
"runs": len(agent_results),
|
| 357 |
+
"avg_score": round(avg_score, 4),
|
| 358 |
+
"avg_total_reward": round(avg_reward, 2),
|
| 359 |
+
"avg_steps": round(avg_steps, 1),
|
| 360 |
+
"avg_decision_time_ms": round(avg_time, 2),
|
| 361 |
+
"best_run": max(agent_results, key=lambda r: r.score).episode_id,
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
if avg_score > best_score:
|
| 365 |
+
best_score = avg_score
|
| 366 |
+
best_agent = agent_type
|
| 367 |
+
|
| 368 |
+
summary["winner"] = best_agent
|
| 369 |
+
summary["all_runs"] = [
|
| 370 |
+
{
|
| 371 |
+
"agent": r.agent_type,
|
| 372 |
+
"episode_id": r.episode_id,
|
| 373 |
+
"score": r.score,
|
| 374 |
+
"reward": round(r.total_reward, 2),
|
| 375 |
+
"steps": r.steps,
|
| 376 |
+
}
|
| 377 |
+
for r in results
|
| 378 |
+
]
|
| 379 |
+
|
| 380 |
+
return summary
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# Global arena instance
|
| 384 |
+
_arena = Arena()
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def get_arena() -> Arena:
|
| 388 |
+
"""Get the global arena instance."""
|
| 389 |
+
return _arena
|
dashboard.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real-time Visual Dashboard for Traffic Control Environment.
|
| 3 |
+
|
| 4 |
+
Generates SVG visualizations of the 4-way intersection showing:
|
| 5 |
+
- Vehicles in each queue
|
| 6 |
+
- Traffic light states with color-coded signals
|
| 7 |
+
- Emergency vehicles with flashing indicators
|
| 8 |
+
- Live statistics overlay
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import List, Optional, Dict, Any
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
import json
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class RenderState:
|
| 18 |
+
"""Current state for rendering."""
|
| 19 |
+
current_phase: int
|
| 20 |
+
time_in_phase: int
|
| 21 |
+
queue_lengths: List[int]
|
| 22 |
+
emergency_queue: List[int]
|
| 23 |
+
emergency_urgency: List[int]
|
| 24 |
+
total_vehicles_passed: int = 0
|
| 25 |
+
total_emergency_passed: int = 0
|
| 26 |
+
step_count: int = 0
|
| 27 |
+
reward: float = 0.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_phase_color(phase: int, direction: str) -> str:
|
| 31 |
+
"""Get traffic light color for a given phase and direction."""
|
| 32 |
+
colors = {
|
| 33 |
+
0: {"NS": "#22c55e", "EW": "#ef4444"}, # NS_GREEN
|
| 34 |
+
1: {"NS": "#ef4444", "EW": "#22c55e"}, # EW_GREEN
|
| 35 |
+
2: {"NS": "#ef4444", "EW": "#ef4444"}, # ALL_RED
|
| 36 |
+
3: {"NS": "#eab308", "EW": "#ef4444"}, # NS_YELLOW
|
| 37 |
+
4: {"NS": "#ef4444", "EW": "#eab308"}, # EW_YELLOW
|
| 38 |
+
}
|
| 39 |
+
mapping = colors.get(phase, colors[0])
|
| 40 |
+
return mapping.get(direction, "#ef4444")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def render_intersection(state: RenderState, width: int = 600, height: int = 600) -> str:
|
| 44 |
+
"""
|
| 45 |
+
Render the intersection as an SVG string.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
state: Current simulation state
|
| 49 |
+
width: SVG width in pixels
|
| 50 |
+
height: SVG height in pixels
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
SVG string
|
| 54 |
+
"""
|
| 55 |
+
cx, cy = width // 2, height // 2
|
| 56 |
+
road_width = 80
|
| 57 |
+
lane_width = road_width // 2
|
| 58 |
+
|
| 59 |
+
# Colors
|
| 60 |
+
bg_color = "#1a1a2e"
|
| 61 |
+
road_color = "#2d2d44"
|
| 62 |
+
line_color = "#fbbf24"
|
| 63 |
+
text_color = "#e2e8f0"
|
| 64 |
+
|
| 65 |
+
svg_parts = [
|
| 66 |
+
f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">',
|
| 67 |
+
f'<rect width="{width}" height="{height}" fill="{bg_color}"/>',
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
# Draw roads
|
| 71 |
+
# Vertical road (North-South)
|
| 72 |
+
svg_parts.append(
|
| 73 |
+
f'<rect x="{cx - road_width//2}" y="0" width="{road_width}" height="{height}" fill="{road_color}"/>'
|
| 74 |
+
)
|
| 75 |
+
# Horizontal road (East-West)
|
| 76 |
+
svg_parts.append(
|
| 77 |
+
f'<rect x="0" y="{cy - road_width//2}" width="{width}" height="{road_width}" fill="{road_color}"/>'
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Draw center intersection
|
| 81 |
+
svg_parts.append(
|
| 82 |
+
f'<rect x="{cx - road_width//2}" y="{cy - road_width//2}" width="{road_width}" height="{road_width}" fill="{road_color}"/>'
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Draw lane markings
|
| 86 |
+
dash_length = 20
|
| 87 |
+
gap_length = 20
|
| 88 |
+
for y in range(0, height, dash_length + gap_length):
|
| 89 |
+
svg_parts.append(
|
| 90 |
+
f'<rect x="{cx - 2}" y="{y}" width="4" height="{dash_length}" fill="{line_color}" opacity="0.5"/>'
|
| 91 |
+
)
|
| 92 |
+
for x in range(0, width, dash_length + gap_length):
|
| 93 |
+
svg_parts.append(
|
| 94 |
+
f'<rect x="{x}" y="{cy - 2}" width="{dash_length}" height="4" fill="{line_color}" opacity="0.5"/>'
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Draw stop lines
|
| 98 |
+
stop_line_offset = road_width // 2 + 10
|
| 99 |
+
svg_parts.extend([
|
| 100 |
+
# North stop line
|
| 101 |
+
f'<line x1="{cx - road_width//2}" y1="{cy - stop_line_offset}" x2="{cx + road_width//2}" y2="{cy - stop_line_offset}" stroke="{line_color}" stroke-width="3"/>',
|
| 102 |
+
# South stop line
|
| 103 |
+
f'<line x1="{cx - road_width//2}" y1="{cy + stop_line_offset}" x2="{cx + road_width//2}" y2="{cy + stop_line_offset}" stroke="{line_color}" stroke-width="3"/>',
|
| 104 |
+
# East stop line
|
| 105 |
+
f'<line x1="{cx + stop_line_offset}" y1="{cy - road_width//2}" x2="{cx + stop_line_offset}" y2="{cy + road_width//2}" stroke="{line_color}" stroke-width="3"/>',
|
| 106 |
+
# West stop line
|
| 107 |
+
f'<line x1="{cx - stop_line_offset}" y1="{cy - road_width//2}" x2="{cx - stop_line_offset}" y2="{cy + road_width//2}" stroke="{line_color}" stroke-width="3"/>',
|
| 108 |
+
])
|
| 109 |
+
|
| 110 |
+
# Draw traffic lights
|
| 111 |
+
light_radius = 12
|
| 112 |
+
ns_color = get_phase_color(state.current_phase, "NS")
|
| 113 |
+
ew_color = get_phase_color(state.current_phase, "EW")
|
| 114 |
+
|
| 115 |
+
# North light
|
| 116 |
+
svg_parts.append(
|
| 117 |
+
f'<circle cx="{cx + road_width//2 + 20}" cy="{cy - stop_line_offset}" r="{light_radius}" fill="{ns_color}" stroke="white" stroke-width="2"/>'
|
| 118 |
+
)
|
| 119 |
+
# South light
|
| 120 |
+
svg_parts.append(
|
| 121 |
+
f'<circle cx="{cx - road_width//2 - 20}" cy="{cy + stop_line_offset}" r="{light_radius}" fill="{ns_color}" stroke="white" stroke-width="2"/>'
|
| 122 |
+
)
|
| 123 |
+
# East light
|
| 124 |
+
svg_parts.append(
|
| 125 |
+
f'<circle cx="{cx + stop_line_offset}" cy="{cy + road_width//2 + 20}" r="{light_radius}" fill="{ew_color}" stroke="white" stroke-width="2"/>'
|
| 126 |
+
)
|
| 127 |
+
# West light
|
| 128 |
+
svg_parts.append(
|
| 129 |
+
f'<circle cx="{cx - stop_line_offset}" cy="{cy - road_width//2 - 20}" r="{light_radius}" fill="{ew_color}" stroke="white" stroke-width="2"/>'
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Draw vehicles in queues
|
| 133 |
+
car_width = 24
|
| 134 |
+
car_height = 36
|
| 135 |
+
truck_width = 28
|
| 136 |
+
truck_height = 44
|
| 137 |
+
|
| 138 |
+
def draw_vehicle(x: float, y: float, is_emergency: bool, urgency: int, rotation: int = 0):
|
| 139 |
+
if is_emergency:
|
| 140 |
+
# Emergency vehicle (flashing red/blue)
|
| 141 |
+
flash = "#ef4444" if state.step_count % 4 < 2 else "#3b82f6"
|
| 142 |
+
return (
|
| 143 |
+
f'<g transform="translate({x},{y}) rotate({rotation})">'
|
| 144 |
+
f'<rect x="{-truck_width//2}" y="{-truck_height//2}" width="{truck_width}" height="{truck_height}" rx="4" fill="{flash}" stroke="white" stroke-width="2"/>'
|
| 145 |
+
f'<text x="0" y="4" text-anchor="middle" fill="white" font-size="10" font-weight="bold">π¨</text>'
|
| 146 |
+
f'<text x="0" y="{-truck_height//2 - 8}" text-anchor="middle" fill="#ef4444" font-size="12" font-weight="bold">!{urgency}</text>'
|
| 147 |
+
f'</g>'
|
| 148 |
+
)
|
| 149 |
+
else:
|
| 150 |
+
# Regular car
|
| 151 |
+
car_colors = ["#60a5fa", "#34d399", "#f472b6", "#fbbf24"]
|
| 152 |
+
color = car_colors[(int(x) + int(y)) % len(car_colors)]
|
| 153 |
+
return (
|
| 154 |
+
f'<g transform="translate({x},{y}) rotate({rotation})">'
|
| 155 |
+
f'<rect x="{-car_width//2}" y="{-car_height//2}" width="{car_width}" height="{car_height}" rx="3" fill="{color}" stroke="white" stroke-width="1"/>'
|
| 156 |
+
f'<rect x="{-car_width//2 + 4}" y="{-car_height//2 + 4}" width="{car_width - 8}" height="{car_height//3}" rx="2" fill="#1e293b"/>'
|
| 157 |
+
f'</g>'
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Draw North queue (approaching from top)
|
| 161 |
+
nx = cx - lane_width // 2
|
| 162 |
+
for i in range(min(state.queue_lengths[0], 8)):
|
| 163 |
+
y = 30 + i * 45
|
| 164 |
+
svg_parts.append(draw_vehicle(nx, y, False, 0, 180))
|
| 165 |
+
for i in range(min(state.emergency_queue[0], 2)):
|
| 166 |
+
y = 30 + (state.queue_lengths[0] + i) * 45
|
| 167 |
+
urgency = state.emergency_urgency[0] if i == 0 else 5
|
| 168 |
+
svg_parts.append(draw_vehicle(nx, y, True, urgency, 180))
|
| 169 |
+
|
| 170 |
+
# Draw South queue (approaching from bottom)
|
| 171 |
+
sx = cx + lane_width // 2
|
| 172 |
+
for i in range(min(state.queue_lengths[1], 8)):
|
| 173 |
+
y = height - 30 - i * 45
|
| 174 |
+
svg_parts.append(draw_vehicle(sx, y, False, 0, 0))
|
| 175 |
+
for i in range(min(state.emergency_queue[1], 2)):
|
| 176 |
+
y = height - 30 - (state.queue_lengths[1] + i) * 45
|
| 177 |
+
urgency = state.emergency_urgency[1] if i == 0 else 5
|
| 178 |
+
svg_parts.append(draw_vehicle(sx, y, True, urgency, 0))
|
| 179 |
+
|
| 180 |
+
# Draw East queue (approaching from right)
|
| 181 |
+
ey = cy + lane_width // 2
|
| 182 |
+
for i in range(min(state.queue_lengths[2], 8)):
|
| 183 |
+
x = width - 30 - i * 45
|
| 184 |
+
svg_parts.append(draw_vehicle(x, ey, False, 0, 270))
|
| 185 |
+
for i in range(min(state.emergency_queue[2], 2)):
|
| 186 |
+
x = width - 30 - (state.queue_lengths[2] + i) * 45
|
| 187 |
+
urgency = state.emergency_urgency[2] if i == 0 else 5
|
| 188 |
+
svg_parts.append(draw_vehicle(x, ey, True, urgency, 270))
|
| 189 |
+
|
| 190 |
+
# Draw West queue (approaching from left)
|
| 191 |
+
wy = cy - lane_width // 2
|
| 192 |
+
for i in range(min(state.queue_lengths[3], 8)):
|
| 193 |
+
x = 30 + i * 45
|
| 194 |
+
svg_parts.append(draw_vehicle(x, wy, False, 0, 90))
|
| 195 |
+
for i in range(min(state.emergency_queue[3], 2)):
|
| 196 |
+
x = 30 + (state.queue_lengths[3] + i) * 45
|
| 197 |
+
urgency = state.emergency_urgency[3] if i == 0 else 5
|
| 198 |
+
svg_parts.append(draw_vehicle(x, wy, True, urgency, 90))
|
| 199 |
+
|
| 200 |
+
# Draw labels
|
| 201 |
+
svg_parts.extend([
|
| 202 |
+
f'<text x="{width//2}" y="25" text-anchor="middle" fill="{text_color}" font-size="18" font-weight="bold">π¦ Traffic Control Dashboard</text>',
|
| 203 |
+
f'<text x="15" y="{height//2 - 60}" fill="{text_color}" font-size="14" transform="rotate(-90, 15, {height//2})">West ({state.queue_lengths[3]}π {state.emergency_queue[3]}π¨)</text>',
|
| 204 |
+
f'<text x="{width - 15}" y="{height//2 - 60}" fill="{text_color}" font-size="14" transform="rotate(90, {width - 15}, {height//2})">East ({state.queue_lengths[2]}π {state.emergency_queue[2]}π¨)</text>',
|
| 205 |
+
f'<text x="{width//2}" y="{height - 10}" text-anchor="middle" fill="{text_color}" font-size="14">South ({state.queue_lengths[1]}π {state.emergency_queue[1]}π¨)</text>',
|
| 206 |
+
f'<text x="{width//2}" y="{height - 30}" text-anchor="middle" fill="{text_color}" font-size="14">North ({state.queue_lengths[0]}π {state.emergency_queue[0]}π¨)</text>',
|
| 207 |
+
])
|
| 208 |
+
|
| 209 |
+
# Draw stats panel
|
| 210 |
+
stats_x = 10
|
| 211 |
+
stats_y = height - 100
|
| 212 |
+
svg_parts.append(
|
| 213 |
+
f'<rect x="{stats_x}" y="{stats_y}" width="180" height="90" rx="8" fill="rgba(0,0,0,0.5)" stroke="{text_color}" stroke-width="1"/>'
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
phase_names = {0: "NS GREEN", 1: "EW GREEN", 2: "ALL RED", 3: "NS YELLOW", 4: "EW YELLOW"}
|
| 217 |
+
phase_name = phase_names.get(state.current_phase, "UNKNOWN")
|
| 218 |
+
|
| 219 |
+
stats_text = [
|
| 220 |
+
f"Step: {state.step_count}",
|
| 221 |
+
f"Phase: {phase_name} ({state.time_in_phase}s)",
|
| 222 |
+
f"Reward: {state.reward:.2f}",
|
| 223 |
+
f"Passed: {state.total_vehicles_passed}π {state.total_emergency_passed}π¨",
|
| 224 |
+
]
|
| 225 |
+
|
| 226 |
+
for i, line in enumerate(stats_text):
|
| 227 |
+
svg_parts.append(
|
| 228 |
+
f'<text x="{stats_x + 10}" y="{stats_y + 20 + i * 20}" fill="{text_color}" font-size="12" font-family="monospace">{line}</text>'
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
svg_parts.append('</svg>')
|
| 232 |
+
return '\n'.join(svg_parts)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def observation_to_render_state(
|
| 236 |
+
obs: Dict[str, Any],
|
| 237 |
+
total_vehicles: int = 0,
|
| 238 |
+
total_emergency: int = 0,
|
| 239 |
+
step: int = 0,
|
| 240 |
+
reward: float = 0.0,
|
| 241 |
+
) -> RenderState:
|
| 242 |
+
"""Convert observation dict to RenderState."""
|
| 243 |
+
return RenderState(
|
| 244 |
+
current_phase=obs.get("current_phase", 0),
|
| 245 |
+
time_in_phase=obs.get("time_in_phase", 0),
|
| 246 |
+
queue_lengths=obs.get("queue_lengths", [0, 0, 0, 0]),
|
| 247 |
+
emergency_queue=obs.get("emergency_queue", [0, 0, 0, 0]),
|
| 248 |
+
emergency_urgency=obs.get("emergency_urgency", [0, 0, 0, 0]),
|
| 249 |
+
total_vehicles_passed=total_vehicles,
|
| 250 |
+
total_emergency_passed=total_emergency,
|
| 251 |
+
step_count=step,
|
| 252 |
+
reward=reward,
|
| 253 |
+
)
|
environment.py
CHANGED
|
@@ -5,11 +5,12 @@ Implements openenv-core's Environment interface so it works directly
|
|
| 5 |
with create_app() β no adapters needed.
|
| 6 |
|
| 7 |
Simulates a 4-way intersection with:
|
| 8 |
-
-
|
| 9 |
-
-
|
|
|
|
| 10 |
- Yellow-light transition state machine (2-step yellow)
|
| 11 |
- Traffic-surge events (hard task only)
|
| 12 |
-
- Multi-objective reward function
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -84,7 +85,7 @@ YELLOW_DURATION = 2
|
|
| 84 |
|
| 85 |
|
| 86 |
# ---------------------------------------------------------------------------
|
| 87 |
-
# Task configurations
|
| 88 |
# ---------------------------------------------------------------------------
|
| 89 |
|
| 90 |
TASK_CONFIGS: Dict[str, dict] = {
|
|
@@ -96,6 +97,12 @@ TASK_CONFIGS: Dict[str, dict] = {
|
|
| 96 |
"max_queue_per_lane": 20,
|
| 97 |
"surge_probability": 0.0,
|
| 98 |
"surge_multiplier": 1.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
},
|
| 100 |
"emergency_priority": {
|
| 101 |
"vehicle_arrival_rate": 0.5,
|
|
@@ -105,6 +112,10 @@ TASK_CONFIGS: Dict[str, dict] = {
|
|
| 105 |
"max_queue_per_lane": 20,
|
| 106 |
"surge_probability": 0.0,
|
| 107 |
"surge_multiplier": 1.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
},
|
| 109 |
"dynamic_scenarios": {
|
| 110 |
"vehicle_arrival_rate": 0.7,
|
|
@@ -114,6 +125,10 @@ TASK_CONFIGS: Dict[str, dict] = {
|
|
| 114 |
"max_queue_per_lane": 30,
|
| 115 |
"surge_probability": 0.04,
|
| 116 |
"surge_multiplier": 3.0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
},
|
| 118 |
}
|
| 119 |
|
|
@@ -163,6 +178,7 @@ class TrafficControlEnvironment(Environment):
|
|
| 163 |
self._episode_id: str = ""
|
| 164 |
self._step_count: int = 0
|
| 165 |
self._queues: List[List[Vehicle]] = [[] for _ in range(4)]
|
|
|
|
| 166 |
self._current_phase: LightPhase = LightPhase.NS_GREEN
|
| 167 |
self._time_in_phase: int = 0
|
| 168 |
self._pending_phase: Optional[int] = None
|
|
@@ -194,6 +210,7 @@ class TrafficControlEnvironment(Environment):
|
|
| 194 |
self._episode_id = episode_id or str(uuid.uuid4())
|
| 195 |
self._step_count = 0
|
| 196 |
self._queues = [[] for _ in range(4)]
|
|
|
|
| 197 |
self._current_phase = LightPhase.NS_GREEN
|
| 198 |
self._time_in_phase = 0
|
| 199 |
self._pending_phase = None
|
|
@@ -211,6 +228,11 @@ class TrafficControlEnvironment(Environment):
|
|
| 211 |
"""Execute one simulation step."""
|
| 212 |
self._step_count += 1
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
self._spawn_vehicles()
|
| 215 |
phase_changed = self._apply_action(action)
|
| 216 |
self._advance_phase()
|
|
@@ -249,13 +271,27 @@ class TrafficControlEnvironment(Environment):
|
|
| 249 |
# Simulation internals
|
| 250 |
# ------------------------------------------------------------------
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
def _spawn_vehicles(self) -> None:
|
| 253 |
-
|
| 254 |
-
em
|
| 255 |
-
urg
|
| 256 |
-
surge_p
|
| 257 |
-
surge_m
|
| 258 |
-
max_q
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
surge_dir = -1
|
| 261 |
surge_extra = 0
|
|
@@ -264,7 +300,9 @@ class TrafficControlEnvironment(Environment):
|
|
| 264 |
surge_extra = max(0, int(self._rng.gauss(3, 1) * surge_m))
|
| 265 |
|
| 266 |
for d in range(4):
|
| 267 |
-
|
|
|
|
|
|
|
| 268 |
if d == surge_dir:
|
| 269 |
n += surge_extra
|
| 270 |
for _ in range(n):
|
|
@@ -345,6 +383,8 @@ class TrafficControlEnvironment(Environment):
|
|
| 345 |
v.waiting_time += 1
|
| 346 |
total += 1.0
|
| 347 |
if v.vehicle_type == VehicleType.EMERGENCY:
|
|
|
|
|
|
|
| 348 |
self._total_emergency_delay += 1.0
|
| 349 |
return total
|
| 350 |
|
|
@@ -362,26 +402,42 @@ class TrafficControlEnvironment(Environment):
|
|
| 362 |
collision: bool,
|
| 363 |
phase_changed: bool,
|
| 364 |
) -> float:
|
| 365 |
-
|
| 366 |
-
r
|
| 367 |
-
r
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
for d in range(4):
|
| 370 |
for v in self._queues[d]:
|
| 371 |
if v.vehicle_type == VehicleType.EMERGENCY:
|
| 372 |
-
r -= v.urgency * 0.
|
| 373 |
|
|
|
|
| 374 |
if collision:
|
| 375 |
r -= 200.0
|
| 376 |
|
|
|
|
| 377 |
if phase_changed:
|
| 378 |
p = int(self._current_phase)
|
|
|
|
| 379 |
if p == PHASE_NS_GREEN:
|
| 380 |
-
|
| 381 |
-
r -= 0.5
|
| 382 |
elif p == PHASE_EW_GREEN:
|
| 383 |
-
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
return r
|
| 387 |
|
|
@@ -409,6 +465,17 @@ class TrafficControlEnvironment(Environment):
|
|
| 409 |
emergency_queue.append(em)
|
| 410 |
emergency_urgency.append(max_u)
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
return TrafficObservation(
|
| 413 |
current_phase=int(self._current_phase),
|
| 414 |
time_in_phase=self._time_in_phase,
|
|
@@ -417,6 +484,10 @@ class TrafficControlEnvironment(Environment):
|
|
| 417 |
emergency_urgency=emergency_urgency,
|
| 418 |
vehicles_passed=vehicles_passed,
|
| 419 |
emergency_passed=emergency_passed,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
total_waiting_time=waiting_delta,
|
| 421 |
collision=collision,
|
| 422 |
reward=reward,
|
|
|
|
| 5 |
with create_app() β no adapters needed.
|
| 6 |
|
| 7 |
Simulates a 4-way intersection with:
|
| 8 |
+
- Time-varying traffic wave patterns (sinusoidal arrival rates)
|
| 9 |
+
- Directional traffic imbalance (rush-hour asymmetry)
|
| 10 |
+
- Emergency vehicles with urgency levels (0-10) that escalate over time
|
| 11 |
- Yellow-light transition state machine (2-step yellow)
|
| 12 |
- Traffic-surge events (hard task only)
|
| 13 |
+
- Multi-objective reward function aligned with grading weights
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
# ---------------------------------------------------------------------------
|
| 88 |
+
# Task configurations β enhanced with wave/imbalance parameters
|
| 89 |
# ---------------------------------------------------------------------------
|
| 90 |
|
| 91 |
TASK_CONFIGS: Dict[str, dict] = {
|
|
|
|
| 97 |
"max_queue_per_lane": 20,
|
| 98 |
"surge_probability": 0.0,
|
| 99 |
"surge_multiplier": 1.0,
|
| 100 |
+
# Wave pattern: sinusoidal variation in arrival rate
|
| 101 |
+
"wave_amplitude": 0.15, # Β±15% variation
|
| 102 |
+
"wave_period": 40, # steps per wave cycle
|
| 103 |
+
# Directional imbalance: multiplier for NS vs EW
|
| 104 |
+
"ns_bias": 1.2, # NS gets 20% more traffic
|
| 105 |
+
"ew_bias": 0.8,
|
| 106 |
},
|
| 107 |
"emergency_priority": {
|
| 108 |
"vehicle_arrival_rate": 0.5,
|
|
|
|
| 112 |
"max_queue_per_lane": 20,
|
| 113 |
"surge_probability": 0.0,
|
| 114 |
"surge_multiplier": 1.0,
|
| 115 |
+
"wave_amplitude": 0.20,
|
| 116 |
+
"wave_period": 50,
|
| 117 |
+
"ns_bias": 1.0,
|
| 118 |
+
"ew_bias": 1.0,
|
| 119 |
},
|
| 120 |
"dynamic_scenarios": {
|
| 121 |
"vehicle_arrival_rate": 0.7,
|
|
|
|
| 125 |
"max_queue_per_lane": 30,
|
| 126 |
"surge_probability": 0.04,
|
| 127 |
"surge_multiplier": 3.0,
|
| 128 |
+
"wave_amplitude": 0.25,
|
| 129 |
+
"wave_period": 60,
|
| 130 |
+
"ns_bias": 1.3,
|
| 131 |
+
"ew_bias": 0.7,
|
| 132 |
},
|
| 133 |
}
|
| 134 |
|
|
|
|
| 178 |
self._episode_id: str = ""
|
| 179 |
self._step_count: int = 0
|
| 180 |
self._queues: List[List[Vehicle]] = [[] for _ in range(4)]
|
| 181 |
+
self._prev_queue_lengths: List[int] = [0, 0, 0, 0]
|
| 182 |
self._current_phase: LightPhase = LightPhase.NS_GREEN
|
| 183 |
self._time_in_phase: int = 0
|
| 184 |
self._pending_phase: Optional[int] = None
|
|
|
|
| 210 |
self._episode_id = episode_id or str(uuid.uuid4())
|
| 211 |
self._step_count = 0
|
| 212 |
self._queues = [[] for _ in range(4)]
|
| 213 |
+
self._prev_queue_lengths = [0, 0, 0, 0]
|
| 214 |
self._current_phase = LightPhase.NS_GREEN
|
| 215 |
self._time_in_phase = 0
|
| 216 |
self._pending_phase = None
|
|
|
|
| 228 |
"""Execute one simulation step."""
|
| 229 |
self._step_count += 1
|
| 230 |
|
| 231 |
+
# Snapshot queue lengths before this step for trend tracking
|
| 232 |
+
self._prev_queue_lengths = [
|
| 233 |
+
len(q) for q in self._queues
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
self._spawn_vehicles()
|
| 237 |
phase_changed = self._apply_action(action)
|
| 238 |
self._advance_phase()
|
|
|
|
| 271 |
# Simulation internals
|
| 272 |
# ------------------------------------------------------------------
|
| 273 |
|
| 274 |
+
def _get_wave_rate(self, base_rate: float) -> float:
|
| 275 |
+
"""Apply sinusoidal wave pattern to arrival rate."""
|
| 276 |
+
amp = self._cfg.get("wave_amplitude", 0.0)
|
| 277 |
+
period = self._cfg.get("wave_period", 40)
|
| 278 |
+
if amp <= 0 or period <= 0:
|
| 279 |
+
return base_rate
|
| 280 |
+
wave = math.sin(2 * math.pi * self._step_count / period)
|
| 281 |
+
return max(0.05, base_rate * (1.0 + amp * wave))
|
| 282 |
+
|
| 283 |
def _spawn_vehicles(self) -> None:
|
| 284 |
+
base_arr = self._cfg["vehicle_arrival_rate"]
|
| 285 |
+
em = self._cfg["emergency_arrival_rate"]
|
| 286 |
+
urg = self._cfg["emergency_urgency_range"]
|
| 287 |
+
surge_p = self._cfg["surge_probability"]
|
| 288 |
+
surge_m = self._cfg["surge_multiplier"]
|
| 289 |
+
max_q = self._cfg["max_queue_per_lane"]
|
| 290 |
+
ns_bias = self._cfg.get("ns_bias", 1.0)
|
| 291 |
+
ew_bias = self._cfg.get("ew_bias", 1.0)
|
| 292 |
+
|
| 293 |
+
# Apply wave pattern
|
| 294 |
+
arr = self._get_wave_rate(base_arr)
|
| 295 |
|
| 296 |
surge_dir = -1
|
| 297 |
surge_extra = 0
|
|
|
|
| 300 |
surge_extra = max(0, int(self._rng.gauss(3, 1) * surge_m))
|
| 301 |
|
| 302 |
for d in range(4):
|
| 303 |
+
# Directional bias: NS directions (0,1) vs EW (2,3)
|
| 304 |
+
dir_bias = ns_bias if d in (0, 1) else ew_bias
|
| 305 |
+
n = self._poisson(arr * dir_bias)
|
| 306 |
if d == surge_dir:
|
| 307 |
n += surge_extra
|
| 308 |
for _ in range(n):
|
|
|
|
| 383 |
v.waiting_time += 1
|
| 384 |
total += 1.0
|
| 385 |
if v.vehicle_type == VehicleType.EMERGENCY:
|
| 386 |
+
# Urgency escalates over time β waiting makes it worse
|
| 387 |
+
v.urgency = min(10, v.urgency + (1 if v.waiting_time % 5 == 0 else 0))
|
| 388 |
self._total_emergency_delay += 1.0
|
| 389 |
return total
|
| 390 |
|
|
|
|
| 402 |
collision: bool,
|
| 403 |
phase_changed: bool,
|
| 404 |
) -> float:
|
| 405 |
+
# --- Throughput reward (aligned with grading target ~1.8-2.0 veh/step) ---
|
| 406 |
+
r = vehicles_passed * 0.30
|
| 407 |
+
r += emergency_passed * 12.0
|
| 408 |
|
| 409 |
+
# --- Waiting penalty (progressive) ---
|
| 410 |
+
r -= waiting_delta * 0.08
|
| 411 |
+
|
| 412 |
+
# --- Emergency urgency penalty (super-linear: urgency^1.5) ---
|
| 413 |
for d in range(4):
|
| 414 |
for v in self._queues[d]:
|
| 415 |
if v.vehicle_type == VehicleType.EMERGENCY:
|
| 416 |
+
r -= (v.urgency ** 1.5) * 0.5
|
| 417 |
|
| 418 |
+
# --- Collision is catastrophic ---
|
| 419 |
if collision:
|
| 420 |
r -= 200.0
|
| 421 |
|
| 422 |
+
# --- Phase change penalty (proportional to wasted switch) ---
|
| 423 |
if phase_changed:
|
| 424 |
p = int(self._current_phase)
|
| 425 |
+
new_dir_queue = 0
|
| 426 |
if p == PHASE_NS_GREEN:
|
| 427 |
+
new_dir_queue = len(self._queues[0]) + len(self._queues[1])
|
|
|
|
| 428 |
elif p == PHASE_EW_GREEN:
|
| 429 |
+
new_dir_queue = len(self._queues[2]) + len(self._queues[3])
|
| 430 |
+
|
| 431 |
+
total_queue = sum(len(q) for q in self._queues)
|
| 432 |
+
if total_queue > 0:
|
| 433 |
+
empty_ratio = 1.0 - (new_dir_queue / total_queue)
|
| 434 |
+
r -= 0.5 + empty_ratio * 1.5 # heavier penalty for switching to emptier side
|
| 435 |
+
else:
|
| 436 |
+
r -= 0.5
|
| 437 |
+
|
| 438 |
+
# --- Stability bonus: reward NOT switching when traffic is flowing ---
|
| 439 |
+
if not phase_changed and vehicles_passed > 0:
|
| 440 |
+
r += 0.05
|
| 441 |
|
| 442 |
return r
|
| 443 |
|
|
|
|
| 465 |
emergency_queue.append(em)
|
| 466 |
emergency_urgency.append(max_u)
|
| 467 |
|
| 468 |
+
# Compute queue trend (current - previous)
|
| 469 |
+
current_totals = [len(q) for q in self._queues]
|
| 470 |
+
queue_trend = [
|
| 471 |
+
current_totals[i] - self._prev_queue_lengths[i]
|
| 472 |
+
for i in range(4)
|
| 473 |
+
]
|
| 474 |
+
|
| 475 |
+
# Compute average wait time
|
| 476 |
+
all_waits = [v.waiting_time for q in self._queues for v in q]
|
| 477 |
+
avg_wait = sum(all_waits) / max(len(all_waits), 1) if all_waits else 0.0
|
| 478 |
+
|
| 479 |
return TrafficObservation(
|
| 480 |
current_phase=int(self._current_phase),
|
| 481 |
time_in_phase=self._time_in_phase,
|
|
|
|
| 484 |
emergency_urgency=emergency_urgency,
|
| 485 |
vehicles_passed=vehicles_passed,
|
| 486 |
emergency_passed=emergency_passed,
|
| 487 |
+
avg_wait_time=round(avg_wait, 2),
|
| 488 |
+
queue_trend=queue_trend,
|
| 489 |
+
total_vehicles_passed_cumulative=self._total_vehicles_passed,
|
| 490 |
+
total_emergency_passed_cumulative=self._total_emergency_passed,
|
| 491 |
total_waiting_time=waiting_delta,
|
| 492 |
collision=collision,
|
| 493 |
reward=reward,
|
inference.py
CHANGED
|
@@ -1,20 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
Inference Script β Autonomous Traffic Control OpenEnv Environment
|
| 3 |
=================================================================
|
|
|
|
|
|
|
| 4 |
Mandatory env variables (injected by validator):
|
| 5 |
API_BASE_URL LLM proxy endpoint (MUST use validator's LiteLLM proxy)
|
| 6 |
MODEL_NAME Model identifier
|
| 7 |
-
|
| 8 |
|
| 9 |
Optional:
|
| 10 |
SERVER_URL Running env server (default: http://localhost:8000)
|
| 11 |
|
| 12 |
-
|
| 13 |
-
API_BASE_URL=<url> API_KEY=<key> python inference.py
|
| 14 |
-
|
| 15 |
-
CRITICAL: This script REQUIRES API_BASE_URL and API_KEY from environment.
|
| 16 |
-
No fallbacks or hardcoded values are used.
|
| 17 |
-
The validator injects these to route calls through LiteLLM proxy.
|
| 18 |
"""
|
| 19 |
|
| 20 |
import os
|
|
@@ -48,137 +45,283 @@ except ImportError:
|
|
| 48 |
from models import TrafficAction, TrafficObservation # type: ignore
|
| 49 |
|
| 50 |
# ---------------------------------------------------------------------------
|
| 51 |
-
# Configuration
|
| 52 |
# ---------------------------------------------------------------------------
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
SEED = 42
|
| 61 |
MAX_TOKENS = 64
|
| 62 |
TEMPERATURE = 0.0
|
| 63 |
|
| 64 |
# ---------------------------------------------------------------------------
|
| 65 |
-
#
|
| 66 |
# ---------------------------------------------------------------------------
|
| 67 |
|
| 68 |
SYSTEM_PROMPT = textwrap.dedent("""
|
| 69 |
-
You are an Autonomous Traffic Control AI managing a 4-way intersection.
|
| 70 |
|
| 71 |
-
OBJECTIVE: Maximise
|
|
|
|
| 72 |
|
| 73 |
PHASES:
|
| 74 |
-
0 = North-South Green (N/S vehicles may pass)
|
| 75 |
-
1 = East-West Green (E/W vehicles may pass)
|
| 76 |
-
2 = All Red (no vehicles pass β
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
|
| 79 |
-
1. EMERGENCY
|
| 80 |
-
|
| 81 |
-
Urgency 8-10 is
|
| 82 |
|
| 83 |
-
2.
|
| 84 |
-
|
| 85 |
|
| 86 |
-
3.
|
| 87 |
-
|
| 88 |
-
- If within 2 vehicles, stay in current phase to avoid switch penalty.
|
| 89 |
|
| 90 |
-
4.
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
-
- Vehicles passing: +0.2 each
|
| 95 |
-
- Emergency vehicles passing: +10 each
|
| 96 |
-
- Phase change with empty queue: -0.5 penalty
|
| 97 |
-
- Emergency waiting: -0.4 * urgency per step (HUGE penalty)
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
{"light_phase": <0, 1, or 2>}
|
| 101 |
""").strip()
|
| 102 |
|
| 103 |
|
| 104 |
-
def _build_prompt(obs: TrafficObservation) -> str:
|
|
|
|
| 105 |
q = obs.queue_lengths
|
| 106 |
em_q = obs.emergency_queue
|
| 107 |
em_u = obs.emergency_urgency
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return textwrap.dedent(f"""
|
|
|
|
|
|
|
| 109 |
CURRENT STATE:
|
| 110 |
-
Active phase : {obs.current_phase}
|
| 111 |
Steps in phase : {obs.time_in_phase}
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
Emergency queue : N={em_q[0]}, S={em_q[1]}, E={em_q[2]}, W={em_q[3]}
|
| 114 |
Emergency urgency : N={em_u[0]}, S={em_u[1]}, E={em_u[2]}, W={em_u[3]}
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
""").strip()
|
| 118 |
|
| 119 |
# ---------------------------------------------------------------------------
|
| 120 |
-
#
|
| 121 |
# ---------------------------------------------------------------------------
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
|
| 131 |
-
ns_em_urgency = em_u[0] + em_u[1] + em_q[0] * 2 + em_q[1] * 2
|
| 132 |
-
ew_em_urgency = em_u[2] + em_u[3] + em_q[2] * 2 + em_q[3] * 2
|
| 133 |
|
| 134 |
-
if ns_em_urgency > 0 or ew_em_urgency > 0:
|
| 135 |
-
# Emergency waiting - switch immediately to help them
|
| 136 |
-
return TrafficAction(light_phase=0 if ns_em_urgency >= ew_em_urgency else 1)
|
| 137 |
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
ns_total = q[0] + q[1]
|
| 140 |
ew_total = q[2] + q[3]
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
#
|
| 143 |
-
min_phase_time = min(3 + max(ns_total, ew_total) // 5, 8)
|
| 144 |
-
|
| 145 |
-
# Stay in current phase if below min time and still has traffic
|
| 146 |
-
if current == 0 and time_in < min_phase_time and ns_total > 0:
|
| 147 |
-
return TrafficAction(light_phase=0)
|
| 148 |
-
if current == 1 and time_in < min_phase_time and ew_total > 0:
|
| 149 |
-
return TrafficAction(light_phase=1)
|
| 150 |
-
|
| 151 |
-
# Switch to direction with more traffic (with 2-vehicle hysteresis to prevent flip-flopping)
|
| 152 |
-
if ns_total >= ew_total + 2:
|
| 153 |
-
return TrafficAction(light_phase=0)
|
| 154 |
-
elif ew_total >= ns_total + 2:
|
| 155 |
-
return TrafficAction(light_phase=1)
|
| 156 |
-
else:
|
| 157 |
-
# Within 2 vehicles - stay in current phase to avoid switch penalty
|
| 158 |
-
return TrafficAction(light_phase=current if current in (0, 1) else 0)
|
| 159 |
|
| 160 |
-
#
|
| 161 |
-
|
| 162 |
-
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
# ---------------------------------------------------------------------------
|
| 184 |
# Grade fetcher
|
|
@@ -211,65 +354,72 @@ def _fetch_score(task_id: str, state_payload: dict) -> float:
|
|
| 211 |
# ---------------------------------------------------------------------------
|
| 212 |
|
| 213 |
def run_task(task_id: str, client: OpenAI) -> None:
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
rewards: List[float] = []
|
| 217 |
success = False
|
|
|
|
|
|
|
| 218 |
|
| 219 |
try:
|
| 220 |
with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
|
| 221 |
step_result = env.reset(task_id=task_id, seed=SEED)
|
| 222 |
-
step =
|
|
|
|
| 223 |
|
| 224 |
while not step_result.done:
|
| 225 |
-
obs
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
action = get_llm_action(client, obs)
|
| 229 |
|
| 230 |
-
|
|
|
|
| 231 |
|
| 232 |
try:
|
| 233 |
step_result = env.step(action)
|
| 234 |
-
reward_val
|
| 235 |
rewards.append(reward_val)
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
print(
|
| 239 |
f"[STEP] step={step} action={action_str} "
|
| 240 |
f"reward={reward_val:.2f} done={done_val} error={error_val}",
|
| 241 |
flush=True,
|
| 242 |
)
|
| 243 |
except Exception as exc:
|
| 244 |
-
env_err = str(exc)
|
|
|
|
| 245 |
print(
|
| 246 |
f"[STEP] step={step} action={action_str} "
|
| 247 |
f"reward=0.00 done=true error={env_err}",
|
| 248 |
flush=True,
|
| 249 |
)
|
|
|
|
| 250 |
break
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
success = True
|
| 255 |
|
| 256 |
except Exception as exc:
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
success = False
|
| 259 |
|
| 260 |
rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00"
|
| 261 |
|
| 262 |
-
score = 0.5
|
| 263 |
-
try:
|
| 264 |
-
state_resp = _http.get(f"{SERVER_URL}/state", timeout=10)
|
| 265 |
-
if state_resp.status_code == 200:
|
| 266 |
-
score = _fetch_score(task_id, state_resp.json())
|
| 267 |
-
except Exception:
|
| 268 |
-
pass
|
| 269 |
-
|
| 270 |
print(
|
| 271 |
-
f"[END] success={str(success).lower()} steps={
|
| 272 |
-
f"score={score:.3f} rewards={rewards_str}",
|
| 273 |
flush=True,
|
| 274 |
)
|
| 275 |
|
|
@@ -278,35 +428,17 @@ def run_task(task_id: str, client: OpenAI) -> None:
|
|
| 278 |
# ---------------------------------------------------------------------------
|
| 279 |
|
| 280 |
def main() -> None:
|
| 281 |
-
|
| 282 |
-
print(
|
| 283 |
-
f"[CONFIG] API_BASE_URL={API_BASE_URL} (len={len(API_BASE_URL)}) "
|
| 284 |
-
f"API_KEY={API_KEY[:10]}... (len={len(API_KEY)}) "
|
| 285 |
-
f"MODEL_NAME={MODEL_NAME}",
|
| 286 |
-
flush=True,
|
| 287 |
-
)
|
| 288 |
-
|
| 289 |
-
# Ensure env vars are not empty
|
| 290 |
-
if not API_BASE_URL or not API_KEY:
|
| 291 |
-
raise SystemExit(f"[FATAL] Empty env vars: API_BASE_URL='{API_BASE_URL}', API_KEY empty={not API_KEY}")
|
| 292 |
-
|
| 293 |
-
# Create the OpenAI client once using module-level env vars
|
| 294 |
-
client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 295 |
-
|
| 296 |
-
# Verify the client is configured correctly by making a test call
|
| 297 |
-
try:
|
| 298 |
-
_ = client.chat.completions.create(
|
| 299 |
-
model=MODEL_NAME,
|
| 300 |
-
messages=[{"role": "user", "content": "test"}],
|
| 301 |
-
max_tokens=1,
|
| 302 |
-
)
|
| 303 |
-
except Exception as e:
|
| 304 |
-
print(f"[WARN] Test call failed: {e}", flush=True)
|
| 305 |
|
| 306 |
for task in ["basic_flow", "emergency_priority", "dynamic_scenarios"]:
|
| 307 |
run_task(task, client)
|
| 308 |
-
print(flush=True)
|
| 309 |
|
| 310 |
|
| 311 |
if __name__ == "__main__":
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Inference Script β Autonomous Traffic Control OpenEnv Environment
|
| 3 |
=================================================================
|
| 4 |
+
Advanced Hybrid Agent: combines optimized rule engine + LLM for ambiguous cases.
|
| 5 |
+
|
| 6 |
Mandatory env variables (injected by validator):
|
| 7 |
API_BASE_URL LLM proxy endpoint (MUST use validator's LiteLLM proxy)
|
| 8 |
MODEL_NAME Model identifier
|
| 9 |
+
HF_TOKEN Hugging Face API token / LiteLLM proxy key
|
| 10 |
|
| 11 |
Optional:
|
| 12 |
SERVER_URL Running env server (default: http://localhost:8000)
|
| 13 |
|
| 14 |
+
Output format: [START], [STEP], [END] lines only (strict protocol compliance)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
import os
|
|
|
|
| 45 |
from models import TrafficAction, TrafficObservation # type: ignore
|
| 46 |
|
| 47 |
# ---------------------------------------------------------------------------
|
| 48 |
+
# Configuration
|
| 49 |
# ---------------------------------------------------------------------------
|
| 50 |
|
| 51 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
|
| 52 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
|
| 53 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 54 |
+
SERVER_URL = os.getenv("SERVER_URL", "http://localhost:8000")
|
| 55 |
+
|
| 56 |
+
if HF_TOKEN is None:
|
| 57 |
+
raise ValueError("HF_TOKEN environment variable is required")
|
| 58 |
|
| 59 |
SEED = 42
|
| 60 |
MAX_TOKENS = 64
|
| 61 |
TEMPERATURE = 0.0
|
| 62 |
|
| 63 |
# ---------------------------------------------------------------------------
|
| 64 |
+
# Enhanced LLM Prompt β scoring-aware
|
| 65 |
# ---------------------------------------------------------------------------
|
| 66 |
|
| 67 |
SYSTEM_PROMPT = textwrap.dedent("""
|
| 68 |
+
You are an elite Autonomous Traffic Control AI managing a 4-way intersection.
|
| 69 |
|
| 70 |
+
OBJECTIVE: Maximise your SCORE by balancing throughput, emergency response,
|
| 71 |
+
efficiency, and stability (avoid unnecessary phase switching).
|
| 72 |
|
| 73 |
PHASES:
|
| 74 |
+
0 = North-South Green (N/S vehicles may pass, up to 3 per direction per step)
|
| 75 |
+
1 = East-West Green (E/W vehicles may pass, up to 3 per direction per step)
|
| 76 |
+
2 = All Red (no vehicles pass β use ONLY for emergency clearance)
|
| 77 |
+
|
| 78 |
+
SCORING COMPONENTS (what you're graded on):
|
| 79 |
+
- Throughput: vehicles cleared per step (target β₯ 1.8/step)
|
| 80 |
+
- Emergency response: clear emergency vehicles FAST (avg delay < 3 steps)
|
| 81 |
+
- Efficiency: minimize total waiting time
|
| 82 |
+
- Adaptability: DON'T switch phases too often (penalty for over-switching!)
|
| 83 |
+
- Stability: staying in a productive phase is rewarded
|
| 84 |
|
| 85 |
+
CRITICAL RULES (apply in strict order):
|
| 86 |
+
1. EMERGENCY VEHICLES: If ANY emergency vehicle is waiting (emergency_queue > 0),
|
| 87 |
+
switch to their direction IMMEDIATELY. Emergency delay is heavily penalized.
|
| 88 |
+
Higher urgency = more critical. Urgency 8-10 is catastrophic.
|
| 89 |
|
| 90 |
+
2. STAY IN PRODUCTIVE PHASE: If current phase is clearing vehicles AND
|
| 91 |
+
queue has traffic, STAY. Each switch costs 2 yellow steps of zero throughput.
|
| 92 |
|
| 93 |
+
3. MINIMUM PHASE TIME: Stay at least 3-5 steps in a phase (more for deeper queues).
|
| 94 |
+
If time_in_phase < 3 and current direction has traffic, STAY.
|
|
|
|
| 95 |
|
| 96 |
+
4. SWITCH ON IMBALANCE: Only switch when the OTHER direction has 3+ more
|
| 97 |
+
vehicles than current direction. Small differences don't justify the switch cost.
|
| 98 |
|
| 99 |
+
5. EMPTY QUEUE: If current direction queue = 0 and other direction > 0, switch.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
6. NEVER use phase 2 (All Red) unless ALL queues are empty.
|
| 102 |
+
|
| 103 |
+
OUTPUT: Exactly one JSON object, no markdown, no explanation:
|
| 104 |
{"light_phase": <0, 1, or 2>}
|
| 105 |
""").strip()
|
| 106 |
|
| 107 |
|
| 108 |
+
def _build_prompt(obs: TrafficObservation, step: int, total_rewards: float) -> str:
|
| 109 |
+
"""Build a rich prompt with scoring context for the LLM."""
|
| 110 |
q = obs.queue_lengths
|
| 111 |
em_q = obs.emergency_queue
|
| 112 |
em_u = obs.emergency_urgency
|
| 113 |
+
|
| 114 |
+
# Queue trend info
|
| 115 |
+
trend = getattr(obs, 'queue_trend', [0, 0, 0, 0])
|
| 116 |
+
avg_wait = getattr(obs, 'avg_wait_time', 0.0)
|
| 117 |
+
|
| 118 |
+
ns_total = q[0] + q[1]
|
| 119 |
+
ew_total = q[2] + q[3]
|
| 120 |
+
ns_em_total = em_q[0] + em_q[1]
|
| 121 |
+
ew_em_total = em_q[2] + em_q[3]
|
| 122 |
+
|
| 123 |
return textwrap.dedent(f"""
|
| 124 |
+
STEP {step} | Cumulative reward: {total_rewards:.1f}
|
| 125 |
+
|
| 126 |
CURRENT STATE:
|
| 127 |
+
Active phase : {obs.current_phase} (0=NS Green, 1=EW Green, 2=All Red)
|
| 128 |
Steps in phase : {obs.time_in_phase}
|
| 129 |
+
|
| 130 |
+
QUEUES:
|
| 131 |
+
Regular vehicles : N={q[0]}, S={q[1]}, E={q[2]}, W={q[3]}
|
| 132 |
+
β NS total: {ns_total} | EW total: {ew_total} | Difference: {abs(ns_total - ew_total)}
|
| 133 |
+
Queue trend (Ξ) : N={trend[0]:+d}, S={trend[1]:+d}, E={trend[2]:+d}, W={trend[3]:+d}
|
| 134 |
+
Avg wait time : {avg_wait:.1f} steps
|
| 135 |
+
|
| 136 |
+
EMERGENCIES:
|
| 137 |
Emergency queue : N={em_q[0]}, S={em_q[1]}, E={em_q[2]}, W={em_q[3]}
|
| 138 |
Emergency urgency : N={em_u[0]}, S={em_u[1]}, E={em_u[2]}, W={em_u[3]}
|
| 139 |
+
β NS emergencies: {ns_em_total} | EW emergencies: {ew_em_total}
|
| 140 |
|
| 141 |
+
DECISION: {{"light_phase": <0, 1, or 2>}}
|
| 142 |
""").strip()
|
| 143 |
|
| 144 |
# ---------------------------------------------------------------------------
|
| 145 |
+
# Advanced rule-based engine β score-maximizing
|
| 146 |
# ---------------------------------------------------------------------------
|
| 147 |
|
| 148 |
+
class SmartRuleEngine:
|
| 149 |
+
"""Stateful rule-based agent that tracks history for better decisions."""
|
| 150 |
+
|
| 151 |
+
def __init__(self):
|
| 152 |
+
self.phase_change_count = 0
|
| 153 |
+
self.total_steps = 0
|
| 154 |
+
self.last_3_queues: List[List[int]] = []
|
| 155 |
+
|
| 156 |
+
def decide(self, obs: TrafficObservation) -> TrafficAction:
|
| 157 |
+
self.total_steps += 1
|
| 158 |
+
|
| 159 |
+
em_q = obs.emergency_queue
|
| 160 |
+
em_u = obs.emergency_urgency
|
| 161 |
+
q = obs.queue_lengths
|
| 162 |
+
current = obs.current_phase
|
| 163 |
+
time_in = obs.time_in_phase
|
| 164 |
+
|
| 165 |
+
# Track queue history for trend analysis
|
| 166 |
+
total_q = [q[i] + em_q[i] for i in range(4)]
|
| 167 |
+
self.last_3_queues.append(total_q)
|
| 168 |
+
if len(self.last_3_queues) > 3:
|
| 169 |
+
self.last_3_queues.pop(0)
|
| 170 |
+
|
| 171 |
+
# ββ Rule 1: EMERGENCY PRIORITY (highest priority, override everything) ββ
|
| 172 |
+
ns_em_score = em_u[0] + em_u[1] + em_q[0] * 3 + em_q[1] * 3
|
| 173 |
+
ew_em_score = em_u[2] + em_u[3] + em_q[2] * 3 + em_q[3] * 3
|
| 174 |
+
|
| 175 |
+
if ns_em_score > 0 or ew_em_score > 0:
|
| 176 |
+
target = 0 if ns_em_score >= ew_em_score else 1
|
| 177 |
+
if target != current:
|
| 178 |
+
self.phase_change_count += 1
|
| 179 |
+
return TrafficAction(light_phase=target)
|
| 180 |
+
|
| 181 |
+
# ββ Rule 2: EMPTY CURRENT DIRECTION β instant switch ββ
|
| 182 |
+
ns_total = q[0] + q[1]
|
| 183 |
+
ew_total = q[2] + q[3]
|
| 184 |
+
|
| 185 |
+
if current == 0 and ns_total == 0 and ew_total > 0:
|
| 186 |
+
self.phase_change_count += 1
|
| 187 |
+
return TrafficAction(light_phase=1)
|
| 188 |
+
if current == 1 and ew_total == 0 and ns_total > 0:
|
| 189 |
+
self.phase_change_count += 1
|
| 190 |
+
return TrafficAction(light_phase=0)
|
| 191 |
+
|
| 192 |
+
# ββ Rule 3: DYNAMIC MINIMUM PHASE TIME ββ
|
| 193 |
+
# Deeper queues β stay longer to maximize throughput before switching
|
| 194 |
+
current_dir_queue = ns_total if current == 0 else ew_total
|
| 195 |
+
other_dir_queue = ew_total if current == 0 else ns_total
|
| 196 |
+
|
| 197 |
+
# Adaptive min time: 3 base + 1 per 4 vehicles, capped at 10
|
| 198 |
+
min_phase_time = min(3 + current_dir_queue // 4, 10)
|
| 199 |
+
|
| 200 |
+
if time_in < min_phase_time and current_dir_queue > 0:
|
| 201 |
+
return TrafficAction(light_phase=current if current in (0, 1) else 0)
|
| 202 |
+
|
| 203 |
+
# ββ Rule 4: ADAPTABILITY-AWARE SWITCHING THRESHOLD ββ
|
| 204 |
+
# The more we've already switched, the higher the threshold to switch again
|
| 205 |
+
switch_rate = self.phase_change_count / max(self.total_steps, 1)
|
| 206 |
+
# Base threshold is 3 vehicles; increases if we're switching too much
|
| 207 |
+
switch_threshold = 3 + int(switch_rate * 10)
|
| 208 |
+
|
| 209 |
+
if current == 0 and ew_total >= ns_total + switch_threshold:
|
| 210 |
+
self.phase_change_count += 1
|
| 211 |
+
return TrafficAction(light_phase=1)
|
| 212 |
+
elif current == 1 and ns_total >= ew_total + switch_threshold:
|
| 213 |
+
self.phase_change_count += 1
|
| 214 |
+
return TrafficAction(light_phase=0)
|
| 215 |
+
|
| 216 |
+
# ββ Rule 5: QUEUE TREND ANALYSIS ββ
|
| 217 |
+
# If other direction's queue is growing fast (trend > 0 for last 3 steps)
|
| 218 |
+
if len(self.last_3_queues) >= 3:
|
| 219 |
+
if current == 0:
|
| 220 |
+
ew_growing = all(
|
| 221 |
+
self.last_3_queues[i][2] + self.last_3_queues[i][3] <=
|
| 222 |
+
self.last_3_queues[i+1][2] + self.last_3_queues[i+1][3]
|
| 223 |
+
for i in range(len(self.last_3_queues) - 1)
|
| 224 |
+
)
|
| 225 |
+
if ew_growing and ew_total > ns_total and time_in >= 3:
|
| 226 |
+
self.phase_change_count += 1
|
| 227 |
+
return TrafficAction(light_phase=1)
|
| 228 |
+
elif current == 1:
|
| 229 |
+
ns_growing = all(
|
| 230 |
+
self.last_3_queues[i][0] + self.last_3_queues[i][1] <=
|
| 231 |
+
self.last_3_queues[i+1][0] + self.last_3_queues[i+1][1]
|
| 232 |
+
for i in range(len(self.last_3_queues) - 1)
|
| 233 |
+
)
|
| 234 |
+
if ns_growing and ns_total > ew_total and time_in >= 3:
|
| 235 |
+
self.phase_change_count += 1
|
| 236 |
+
return TrafficAction(light_phase=0)
|
| 237 |
+
|
| 238 |
+
# ββ Default: STAY in current phase for stability bonus ββ
|
| 239 |
+
return TrafficAction(light_phase=current if current in (0, 1) else 0)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ---------------------------------------------------------------------------
|
| 243 |
+
# Sanitize error strings
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
|
| 246 |
+
def _sanitize(s: str) -> str:
|
| 247 |
+
"""Strip newlines, carriage returns, and problematic characters for output."""
|
| 248 |
+
return s.replace('\n', ' ').replace('\r', ' ').replace('"', "'").replace('\\', '')
|
| 249 |
+
|
| 250 |
+
# ---------------------------------------------------------------------------
|
| 251 |
+
# LLM action with smart fallback
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
|
| 254 |
+
_rule_engine = SmartRuleEngine()
|
|
|
|
|
|
|
| 255 |
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
+
def get_llm_action(
|
| 258 |
+
client: OpenAI,
|
| 259 |
+
obs: TrafficObservation,
|
| 260 |
+
step: int,
|
| 261 |
+
total_rewards: float,
|
| 262 |
+
) -> TrafficAction:
|
| 263 |
+
"""
|
| 264 |
+
Hybrid approach:
|
| 265 |
+
- Use rules for clear-cut decisions (saves API calls + faster)
|
| 266 |
+
- Use LLM for ambiguous situations (close queues, complex emergencies)
|
| 267 |
+
"""
|
| 268 |
+
q = obs.queue_lengths
|
| 269 |
+
em_q = obs.emergency_queue
|
| 270 |
+
current = obs.current_phase
|
| 271 |
+
|
| 272 |
ns_total = q[0] + q[1]
|
| 273 |
ew_total = q[2] + q[3]
|
| 274 |
+
ns_em = sum(em_q[0:2])
|
| 275 |
+
ew_em = sum(em_q[2:4])
|
| 276 |
+
diff = abs(ns_total - ew_total)
|
| 277 |
|
| 278 |
+
# ββ FAST PATH: clear-cut decisions β use rules (no LLM call needed) ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
+
# Emergency vehicles β always rules (speed critical, don't wait for LLM)
|
| 281 |
+
if ns_em > 0 or ew_em > 0:
|
| 282 |
+
return _rule_engine.decide(obs)
|
| 283 |
|
| 284 |
+
# Empty current direction β obvious switch
|
| 285 |
+
if current == 0 and ns_total == 0 and ew_total > 0:
|
| 286 |
+
return _rule_engine.decide(obs)
|
| 287 |
+
if current == 1 and ew_total == 0 and ns_total > 0:
|
| 288 |
+
return _rule_engine.decide(obs)
|
| 289 |
+
|
| 290 |
+
# Large imbalance β obvious switch
|
| 291 |
+
if diff >= 5:
|
| 292 |
+
return _rule_engine.decide(obs)
|
| 293 |
+
|
| 294 |
+
# Very early in phase β obviously stay
|
| 295 |
+
if obs.time_in_phase < 3:
|
| 296 |
+
return _rule_engine.decide(obs)
|
| 297 |
+
|
| 298 |
+
# ββ SLOW PATH: ambiguous situation β ask LLM ββ
|
| 299 |
+
try:
|
| 300 |
+
resp = client.chat.completions.create(
|
| 301 |
+
model=MODEL_NAME,
|
| 302 |
+
messages=[
|
| 303 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 304 |
+
{"role": "user", "content": _build_prompt(obs, step, total_rewards)},
|
| 305 |
+
],
|
| 306 |
+
temperature=TEMPERATURE,
|
| 307 |
+
max_tokens=MAX_TOKENS,
|
| 308 |
+
stream=False,
|
| 309 |
+
timeout=30,
|
| 310 |
+
)
|
| 311 |
+
data_str = (resp.choices[0].message.content or "").strip()
|
| 312 |
+
match = re.search(r'\{[^}]*\}', data_str.replace('\n', ' '))
|
| 313 |
+
data = json.loads(match.group(0) if match else data_str)
|
| 314 |
+
phase = max(0, min(2, int(data.get("light_phase", obs.current_phase))))
|
| 315 |
+
|
| 316 |
+
# Update rule engine state even when using LLM
|
| 317 |
+
_rule_engine.total_steps += 1
|
| 318 |
+
if phase != current:
|
| 319 |
+
_rule_engine.phase_change_count += 1
|
| 320 |
+
|
| 321 |
+
return TrafficAction(light_phase=phase)
|
| 322 |
+
except Exception:
|
| 323 |
+
# LLM failed β use optimized rule-based agent as fallback
|
| 324 |
+
return _rule_engine.decide(obs)
|
| 325 |
|
| 326 |
# ---------------------------------------------------------------------------
|
| 327 |
# Grade fetcher
|
|
|
|
| 354 |
# ---------------------------------------------------------------------------
|
| 355 |
|
| 356 |
def run_task(task_id: str, client: OpenAI) -> None:
|
| 357 |
+
global _rule_engine
|
| 358 |
+
_rule_engine = SmartRuleEngine() # Fresh engine per task
|
| 359 |
+
|
| 360 |
+
print(f"[START] task={task_id} env=traffic_control model={MODEL_NAME}", flush=True)
|
| 361 |
|
| 362 |
rewards: List[float] = []
|
| 363 |
success = False
|
| 364 |
+
step = 0
|
| 365 |
+
total_rewards = 0.0
|
| 366 |
|
| 367 |
try:
|
| 368 |
with TrafficControlEnv(base_url=SERVER_URL).sync() as env:
|
| 369 |
step_result = env.reset(task_id=task_id, seed=SEED)
|
| 370 |
+
step = 0
|
| 371 |
+
broke_on_error = False
|
| 372 |
|
| 373 |
while not step_result.done:
|
| 374 |
+
obs = step_result.observation
|
| 375 |
+
step += 1
|
|
|
|
|
|
|
| 376 |
|
| 377 |
+
action = get_llm_action(client, obs, step, total_rewards)
|
| 378 |
+
action_str = f"light_phase({action.light_phase})"
|
| 379 |
|
| 380 |
try:
|
| 381 |
step_result = env.step(action)
|
| 382 |
+
reward_val = step_result.reward if step_result.reward is not None else 0.0
|
| 383 |
rewards.append(reward_val)
|
| 384 |
+
total_rewards += reward_val
|
| 385 |
+
done_val = str(step_result.done).lower()
|
| 386 |
+
|
| 387 |
+
error_val = "null"
|
| 388 |
+
if hasattr(step_result, 'info') and step_result.info:
|
| 389 |
+
err = step_result.info.get('error')
|
| 390 |
+
if err:
|
| 391 |
+
error_val = _sanitize(str(err))
|
| 392 |
+
|
| 393 |
print(
|
| 394 |
f"[STEP] step={step} action={action_str} "
|
| 395 |
f"reward={reward_val:.2f} done={done_val} error={error_val}",
|
| 396 |
flush=True,
|
| 397 |
)
|
| 398 |
except Exception as exc:
|
| 399 |
+
env_err = _sanitize(str(exc))
|
| 400 |
+
rewards.append(0.0)
|
| 401 |
print(
|
| 402 |
f"[STEP] step={step} action={action_str} "
|
| 403 |
f"reward=0.00 done=true error={env_err}",
|
| 404 |
flush=True,
|
| 405 |
)
|
| 406 |
+
broke_on_error = True
|
| 407 |
break
|
| 408 |
|
| 409 |
+
success = not broke_on_error
|
|
|
|
|
|
|
| 410 |
|
| 411 |
except Exception as exc:
|
| 412 |
+
err_msg = _sanitize(str(exc))
|
| 413 |
+
if step == 0:
|
| 414 |
+
step = 1
|
| 415 |
+
rewards.append(0.0)
|
| 416 |
+
print(f"[STEP] step=1 action=null reward=0.00 done=true error={err_msg}", flush=True)
|
| 417 |
success = False
|
| 418 |
|
| 419 |
rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00"
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
print(
|
| 422 |
+
f"[END] success={str(success).lower()} steps={step} rewards={rewards_str}",
|
|
|
|
| 423 |
flush=True,
|
| 424 |
)
|
| 425 |
|
|
|
|
| 428 |
# ---------------------------------------------------------------------------
|
| 429 |
|
| 430 |
def main() -> None:
|
| 431 |
+
client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
for task in ["basic_flow", "emergency_priority", "dynamic_scenarios"]:
|
| 434 |
run_task(task, client)
|
|
|
|
| 435 |
|
| 436 |
|
| 437 |
if __name__ == "__main__":
|
| 438 |
+
try:
|
| 439 |
+
main()
|
| 440 |
+
except Exception as exc:
|
| 441 |
+
err = _sanitize(str(exc))
|
| 442 |
+
print(f"[START] task=unknown env=traffic_control model={MODEL_NAME}", flush=True)
|
| 443 |
+
print(f"[STEP] step=1 action=null reward=0.00 done=true error={err}", flush=True)
|
| 444 |
+
print(f"[END] success=false steps=1 rewards=0.00", flush=True)
|
models.py
CHANGED
|
@@ -87,6 +87,24 @@ class TrafficObservation(Observation):
|
|
| 87 |
vehicles_passed: int = Field(default=0, description="Regular vehicles cleared this step")
|
| 88 |
emergency_passed: int = Field(default=0, description="Emergency vehicles cleared this step")
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
# -- Penalty signals --
|
| 91 |
total_waiting_time: float = Field(
|
| 92 |
default=0.0,
|
|
|
|
| 87 |
vehicles_passed: int = Field(default=0, description="Regular vehicles cleared this step")
|
| 88 |
emergency_passed: int = Field(default=0, description="Emergency vehicles cleared this step")
|
| 89 |
|
| 90 |
+
# -- Trend & context fields --
|
| 91 |
+
avg_wait_time: float = Field(
|
| 92 |
+
default=0.0,
|
| 93 |
+
description="Average waiting time across all queued vehicles this step",
|
| 94 |
+
)
|
| 95 |
+
queue_trend: List[int] = Field(
|
| 96 |
+
default_factory=lambda: [0, 0, 0, 0],
|
| 97 |
+
description="Queue growth since last step per approach [N,S,E,W] (positive=growing)",
|
| 98 |
+
)
|
| 99 |
+
total_vehicles_passed_cumulative: int = Field(
|
| 100 |
+
default=0,
|
| 101 |
+
description="Cumulative regular vehicles cleared this episode",
|
| 102 |
+
)
|
| 103 |
+
total_emergency_passed_cumulative: int = Field(
|
| 104 |
+
default=0,
|
| 105 |
+
description="Cumulative emergency vehicles cleared this episode",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
# -- Penalty signals --
|
| 109 |
total_waiting_time: float = Field(
|
| 110 |
default=0.0,
|
pyproject.toml
CHANGED
|
@@ -35,5 +35,5 @@ server = "traffic_control.server.app:main"
|
|
| 35 |
include-package-data = true
|
| 36 |
|
| 37 |
[tool.setuptools.packages.find]
|
| 38 |
-
where = [".
|
| 39 |
include = ["traffic_control*"]
|
|
|
|
| 35 |
include-package-data = true
|
| 36 |
|
| 37 |
[tool.setuptools.packages.find]
|
| 38 |
+
where = ["."]
|
| 39 |
include = ["traffic_control*"]
|
server/app.py
CHANGED
|
@@ -33,13 +33,18 @@ for _p in (_PKG_DIR, _ROOT):
|
|
| 33 |
if _p not in sys.path:
|
| 34 |
sys.path.insert(0, _p)
|
| 35 |
|
|
|
|
| 36 |
from openenv.core.env_server.http_server import create_app
|
| 37 |
from fastapi import Request
|
|
|
|
| 38 |
|
| 39 |
# All imports from within traffic_control/ only
|
| 40 |
from traffic_control.models import TrafficAction, TrafficObservation
|
| 41 |
from traffic_control.environment import TrafficControlEnvironment
|
| 42 |
from traffic_control.tasks import grade as run_grader
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
# ---------------------------------------------------------------------------
|
|
@@ -108,6 +113,455 @@ async def grade(request: Request):
|
|
| 108 |
}
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
# ---------------------------------------------------------------------------
|
| 112 |
# 3. Gradio UI (mounted at /ui)
|
| 113 |
# ---------------------------------------------------------------------------
|
|
|
|
| 33 |
if _p not in sys.path:
|
| 34 |
sys.path.insert(0, _p)
|
| 35 |
|
| 36 |
+
from typing import Optional
|
| 37 |
from openenv.core.env_server.http_server import create_app
|
| 38 |
from fastapi import Request
|
| 39 |
+
from fastapi.responses import HTMLResponse
|
| 40 |
|
| 41 |
# All imports from within traffic_control/ only
|
| 42 |
from traffic_control.models import TrafficAction, TrafficObservation
|
| 43 |
from traffic_control.environment import TrafficControlEnvironment
|
| 44 |
from traffic_control.tasks import grade as run_grader
|
| 45 |
+
from traffic_control.dashboard import render_intersection, observation_to_render_state
|
| 46 |
+
from traffic_control.analytics import get_history
|
| 47 |
+
from traffic_control.arena import get_arena
|
| 48 |
|
| 49 |
|
| 50 |
# ---------------------------------------------------------------------------
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Dashboard endpoints
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
@app.get("/dashboard", tags=["dashboard"])
|
| 121 |
+
async def dashboard():
|
| 122 |
+
"""Serve the live dashboard HTML page."""
|
| 123 |
+
html_content = '''<!DOCTYPE html>
|
| 124 |
+
<html>
|
| 125 |
+
<head>
|
| 126 |
+
<title>Traffic Control Dashboard</title>
|
| 127 |
+
<style>
|
| 128 |
+
body {
|
| 129 |
+
font-family: system-ui, -apple-system, sans-serif;
|
| 130 |
+
background: #0f172a;
|
| 131 |
+
color: #e2e8f0;
|
| 132 |
+
margin: 0;
|
| 133 |
+
padding: 20px;
|
| 134 |
+
min-height: 100vh;
|
| 135 |
+
}
|
| 136 |
+
.container {
|
| 137 |
+
max-width: 1200px;
|
| 138 |
+
margin: 0 auto;
|
| 139 |
+
}
|
| 140 |
+
h1 {
|
| 141 |
+
text-align: center;
|
| 142 |
+
margin-bottom: 10px;
|
| 143 |
+
}
|
| 144 |
+
.controls {
|
| 145 |
+
display: flex;
|
| 146 |
+
gap: 10px;
|
| 147 |
+
justify-content: center;
|
| 148 |
+
margin-bottom: 20px;
|
| 149 |
+
flex-wrap: wrap;
|
| 150 |
+
}
|
| 151 |
+
button, select {
|
| 152 |
+
padding: 10px 20px;
|
| 153 |
+
font-size: 14px;
|
| 154 |
+
border-radius: 6px;
|
| 155 |
+
border: none;
|
| 156 |
+
cursor: pointer;
|
| 157 |
+
}
|
| 158 |
+
button {
|
| 159 |
+
background: #3b82f6;
|
| 160 |
+
color: white;
|
| 161 |
+
}
|
| 162 |
+
button:hover {
|
| 163 |
+
background: #2563eb;
|
| 164 |
+
}
|
| 165 |
+
select {
|
| 166 |
+
background: #1e293b;
|
| 167 |
+
color: #e2e8f0;
|
| 168 |
+
border: 1px solid #475569;
|
| 169 |
+
}
|
| 170 |
+
.dashboard-container {
|
| 171 |
+
display: grid;
|
| 172 |
+
grid-template-columns: 1fr 300px;
|
| 173 |
+
gap: 20px;
|
| 174 |
+
}
|
| 175 |
+
.visualization {
|
| 176 |
+
background: #1e293b;
|
| 177 |
+
border-radius: 12px;
|
| 178 |
+
padding: 20px;
|
| 179 |
+
}
|
| 180 |
+
.visualization svg {
|
| 181 |
+
width: 100%;
|
| 182 |
+
height: auto;
|
| 183 |
+
border-radius: 8px;
|
| 184 |
+
}
|
| 185 |
+
.stats-panel {
|
| 186 |
+
background: #1e293b;
|
| 187 |
+
border-radius: 12px;
|
| 188 |
+
padding: 20px;
|
| 189 |
+
}
|
| 190 |
+
.stat-item {
|
| 191 |
+
margin-bottom: 15px;
|
| 192 |
+
padding: 12px;
|
| 193 |
+
background: #0f172a;
|
| 194 |
+
border-radius: 8px;
|
| 195 |
+
}
|
| 196 |
+
.stat-label {
|
| 197 |
+
font-size: 12px;
|
| 198 |
+
color: #94a3b8;
|
| 199 |
+
text-transform: uppercase;
|
| 200 |
+
}
|
| 201 |
+
.stat-value {
|
| 202 |
+
font-size: 24px;
|
| 203 |
+
font-weight: bold;
|
| 204 |
+
color: #22c55e;
|
| 205 |
+
}
|
| 206 |
+
.stat-value.warning {
|
| 207 |
+
color: #eab308;
|
| 208 |
+
}
|
| 209 |
+
.stat-value.danger {
|
| 210 |
+
color: #ef4444;
|
| 211 |
+
}
|
| 212 |
+
.phase-indicator {
|
| 213 |
+
display: inline-block;
|
| 214 |
+
padding: 4px 12px;
|
| 215 |
+
border-radius: 20px;
|
| 216 |
+
font-size: 12px;
|
| 217 |
+
font-weight: bold;
|
| 218 |
+
}
|
| 219 |
+
.phase-ns { background: #22c55e; color: #064e3b; }
|
| 220 |
+
.phase-ew { background: #3b82f6; color: #1e3a8a; }
|
| 221 |
+
.phase-red { background: #ef4444; color: #7f1d1d; }
|
| 222 |
+
.queue-bar {
|
| 223 |
+
height: 8px;
|
| 224 |
+
background: #334155;
|
| 225 |
+
border-radius: 4px;
|
| 226 |
+
margin-top: 5px;
|
| 227 |
+
overflow: hidden;
|
| 228 |
+
}
|
| 229 |
+
.queue-fill {
|
| 230 |
+
height: 100%;
|
| 231 |
+
background: linear-gradient(90deg, #22c55e, #eab308, #ef4444);
|
| 232 |
+
transition: width 0.3s;
|
| 233 |
+
}
|
| 234 |
+
.auto-toggle {
|
| 235 |
+
display: flex;
|
| 236 |
+
align-items: center;
|
| 237 |
+
gap: 10px;
|
| 238 |
+
justify-content: center;
|
| 239 |
+
margin-top: 10px;
|
| 240 |
+
}
|
| 241 |
+
.toggle-switch {
|
| 242 |
+
position: relative;
|
| 243 |
+
width: 50px;
|
| 244 |
+
height: 24px;
|
| 245 |
+
background: #475569;
|
| 246 |
+
border-radius: 12px;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
transition: background 0.3s;
|
| 249 |
+
}
|
| 250 |
+
.toggle-switch.active {
|
| 251 |
+
background: #22c55e;
|
| 252 |
+
}
|
| 253 |
+
.toggle-slider {
|
| 254 |
+
position: absolute;
|
| 255 |
+
top: 2px;
|
| 256 |
+
left: 2px;
|
| 257 |
+
width: 20px;
|
| 258 |
+
height: 20px;
|
| 259 |
+
background: white;
|
| 260 |
+
border-radius: 50%;
|
| 261 |
+
transition: transform 0.3s;
|
| 262 |
+
}
|
| 263 |
+
.toggle-switch.active .toggle-slider {
|
| 264 |
+
transform: translateX(26px);
|
| 265 |
+
}
|
| 266 |
+
</style>
|
| 267 |
+
</head>
|
| 268 |
+
<body>
|
| 269 |
+
<div class="container">
|
| 270 |
+
<h1>π¦ Autonomous Traffic Control Dashboard</h1>
|
| 271 |
+
|
| 272 |
+
<div class="controls">
|
| 273 |
+
<select id="taskSelect">
|
| 274 |
+
<option value="basic_flow">Basic Flow</option>
|
| 275 |
+
<option value="emergency_priority">Emergency Priority</option>
|
| 276 |
+
<option value="dynamic_scenarios">Dynamic Scenarios</option>
|
| 277 |
+
</select>
|
| 278 |
+
<button onclick="resetEnv()">π Reset</button>
|
| 279 |
+
<button onclick="stepOnce()">βΆοΈ Step Once</button>
|
| 280 |
+
<div class="auto-toggle">
|
| 281 |
+
<span>Auto Run</span>
|
| 282 |
+
<div class="toggle-switch" id="autoToggle" onclick="toggleAuto()">
|
| 283 |
+
<div class="toggle-slider"></div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
<button onclick="setPhase(0)">π’ NS Green</button>
|
| 287 |
+
<button onclick="setPhase(1)">π’ EW Green</button>
|
| 288 |
+
<button onclick="setPhase(2)">π΄ All Red</button>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div class="dashboard-container">
|
| 292 |
+
<div class="visualization">
|
| 293 |
+
<div id="svgContainer">Loading...</div>
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<div class="stats-panel">
|
| 297 |
+
<h3>π Live Statistics</h3>
|
| 298 |
+
|
| 299 |
+
<div class="stat-item">
|
| 300 |
+
<div class="stat-label">Current Phase</div>
|
| 301 |
+
<div id="phaseValue" class="stat-value">-</div>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div class="stat-item">
|
| 305 |
+
<div class="stat-label">Step Count</div>
|
| 306 |
+
<div id="stepValue" class="stat-value">0</div>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div class="stat-item">
|
| 310 |
+
<div class="stat-label">Total Reward</div>
|
| 311 |
+
<div id="rewardValue" class="stat-value">0.00</div>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
<div class="stat-item">
|
| 315 |
+
<div class="stat-label">Vehicles Passed</div>
|
| 316 |
+
<div id="vehiclesValue" class="stat-value">0</div>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div class="stat-item">
|
| 320 |
+
<div class="stat-label">Emergency Vehicles</div>
|
| 321 |
+
<div id="emergencyValue" class="stat-value">0</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
<div class="stat-item">
|
| 325 |
+
<div class="stat-label">Queue Depths</div>
|
| 326 |
+
<div id="queueValue" style="font-size: 14px;">N:0 S:0 E:0 W:0</div>
|
| 327 |
+
<div class="queue-bar">
|
| 328 |
+
<div class="queue-fill" id="queueBar" style="width: 0%"></div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<script>
|
| 336 |
+
let autoRunning = false;
|
| 337 |
+
let autoInterval = null;
|
| 338 |
+
let totalReward = 0;
|
| 339 |
+
let totalVehicles = 0;
|
| 340 |
+
let totalEmergency = 0;
|
| 341 |
+
let stepCount = 0;
|
| 342 |
+
|
| 343 |
+
async function resetEnv() {
|
| 344 |
+
const task = document.getElementById('taskSelect').value;
|
| 345 |
+
try {
|
| 346 |
+
await fetch('/reset', {
|
| 347 |
+
method: 'POST',
|
| 348 |
+
headers: {'Content-Type': 'application/json'},
|
| 349 |
+
body: JSON.stringify({task_id: task, seed: 42})
|
| 350 |
+
});
|
| 351 |
+
totalReward = 0;
|
| 352 |
+
totalVehicles = 0;
|
| 353 |
+
totalEmergency = 0;
|
| 354 |
+
stepCount = 0;
|
| 355 |
+
updateDashboard();
|
| 356 |
+
} catch (e) {
|
| 357 |
+
console.error('Reset failed:', e);
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
async function stepOnce() {
|
| 362 |
+
try {
|
| 363 |
+
const response = await fetch('/step', {
|
| 364 |
+
method: 'POST',
|
| 365 |
+
headers: {'Content-Type': 'application/json'},
|
| 366 |
+
body: JSON.stringify({action: {light_phase: 0}})
|
| 367 |
+
});
|
| 368 |
+
const data = await response.json();
|
| 369 |
+
updateStats(data);
|
| 370 |
+
updateDashboard();
|
| 371 |
+
} catch (e) {
|
| 372 |
+
console.error('Step failed:', e);
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
async function setPhase(phase) {
|
| 377 |
+
try {
|
| 378 |
+
const response = await fetch('/step', {
|
| 379 |
+
method: 'POST',
|
| 380 |
+
headers: {'Content-Type': 'application/json'},
|
| 381 |
+
body: JSON.stringify({action: {light_phase: phase}})
|
| 382 |
+
});
|
| 383 |
+
const data = await response.json();
|
| 384 |
+
updateStats(data);
|
| 385 |
+
updateDashboard();
|
| 386 |
+
} catch (e) {
|
| 387 |
+
console.error('Set phase failed:', e);
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
function toggleAuto() {
|
| 392 |
+
autoRunning = !autoRunning;
|
| 393 |
+
document.getElementById('autoToggle').classList.toggle('active', autoRunning);
|
| 394 |
+
|
| 395 |
+
if (autoRunning) {
|
| 396 |
+
autoInterval = setInterval(stepOnce, 1000);
|
| 397 |
+
} else {
|
| 398 |
+
clearInterval(autoInterval);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function updateStats(data) {
|
| 403 |
+
if (data.reward) totalReward += data.reward;
|
| 404 |
+
if (data.metadata && data.metadata.vehicles_passed) {
|
| 405 |
+
totalVehicles = data.metadata.total_vehicles_passed || totalVehicles;
|
| 406 |
+
totalEmergency = data.metadata.total_emergency_passed || totalEmergency;
|
| 407 |
+
}
|
| 408 |
+
stepCount++;
|
| 409 |
+
|
| 410 |
+
document.getElementById('stepValue').textContent = stepCount;
|
| 411 |
+
document.getElementById('rewardValue').textContent = totalReward.toFixed(2);
|
| 412 |
+
document.getElementById('vehiclesValue').textContent = totalVehicles;
|
| 413 |
+
document.getElementById('emergencyValue').textContent = totalEmergency;
|
| 414 |
+
|
| 415 |
+
if (data.observation) {
|
| 416 |
+
const obs = data.observation;
|
| 417 |
+
const phaseNames = {0: 'NS GREEN', 1: 'EW GREEN', 2: 'ALL RED', 3: 'NS YELLOW', 4: 'EW YELLOW'};
|
| 418 |
+
document.getElementById('phaseValue').textContent = phaseNames[obs.current_phase] || 'UNKNOWN';
|
| 419 |
+
|
| 420 |
+
const queues = obs.queue_lengths;
|
| 421 |
+
const totalQueue = queues.reduce((a, b) => a + b, 0) + obs.emergency_queue.reduce((a, b) => a + b, 0);
|
| 422 |
+
document.getElementById('queueValue').textContent = `N:${queues[0]} S:${queues[1]} E:${queues[2]} W:${queues[3]}`;
|
| 423 |
+
document.getElementById('queueBar').style.width = Math.min(totalQueue * 5, 100) + '%';
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
async function updateDashboard() {
|
| 428 |
+
try {
|
| 429 |
+
const response = await fetch('/state');
|
| 430 |
+
const state = await response.json();
|
| 431 |
+
|
| 432 |
+
const svgResponse = await fetch('/dashboard/svg', {
|
| 433 |
+
method: 'POST',
|
| 434 |
+
headers: {'Content-Type': 'application/json'},
|
| 435 |
+
body: JSON.stringify(state)
|
| 436 |
+
});
|
| 437 |
+
const svgData = await svgResponse.json();
|
| 438 |
+
document.getElementById('svgContainer').innerHTML = svgData.svg;
|
| 439 |
+
} catch (e) {
|
| 440 |
+
console.error('Dashboard update failed:', e);
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
// Initial load
|
| 445 |
+
resetEnv();
|
| 446 |
+
</script>
|
| 447 |
+
</body>
|
| 448 |
+
</html>'''
|
| 449 |
+
return HTMLResponse(content=html_content)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@app.post("/dashboard/svg", tags=["dashboard"])
|
| 453 |
+
async def dashboard_svg(request: Request):
|
| 454 |
+
"""Generate SVG visualization from current state."""
|
| 455 |
+
try:
|
| 456 |
+
state_data = await request.json()
|
| 457 |
+
render_state = observation_to_render_state(
|
| 458 |
+
state_data.get("observation", {}),
|
| 459 |
+
total_vehicles=state_data.get("total_vehicles_passed", 0),
|
| 460 |
+
total_emergency=state_data.get("total_emergency_passed", 0),
|
| 461 |
+
step=state_data.get("step_count", 0),
|
| 462 |
+
reward=state_data.get("reward", 0.0),
|
| 463 |
+
)
|
| 464 |
+
svg = render_intersection(render_state)
|
| 465 |
+
return {"svg": svg}
|
| 466 |
+
except Exception as e:
|
| 467 |
+
return {"error": str(e), "svg": ""}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ---------------------------------------------------------------------------
|
| 471 |
+
# Analytics endpoints
|
| 472 |
+
# ---------------------------------------------------------------------------
|
| 473 |
+
|
| 474 |
+
@app.get("/analytics/summary", tags=["analytics"])
|
| 475 |
+
async def analytics_summary(task_id: Optional[str] = None):
|
| 476 |
+
"""Get summary statistics of all recorded episodes."""
|
| 477 |
+
return get_history().get_summary(task_id)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
@app.get("/analytics/episodes", tags=["analytics"])
|
| 481 |
+
async def list_episodes():
|
| 482 |
+
"""List all recorded episodes."""
|
| 483 |
+
history = get_history()
|
| 484 |
+
return {
|
| 485 |
+
"episodes": [
|
| 486 |
+
{
|
| 487 |
+
"episode_id": e.episode_id,
|
| 488 |
+
"task_id": e.task_id,
|
| 489 |
+
"steps": e.steps,
|
| 490 |
+
"total_reward": round(e.total_reward, 2),
|
| 491 |
+
"avg_reward_per_step": round(e.avg_reward_per_step, 4),
|
| 492 |
+
}
|
| 493 |
+
for e in history.episodes
|
| 494 |
+
]
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
@app.get("/analytics/episodes/{episode_id}", tags=["analytics"])
|
| 499 |
+
async def get_episode(episode_id: str):
|
| 500 |
+
"""Get detailed metrics for a specific episode."""
|
| 501 |
+
details = get_history().get_episode_details(episode_id)
|
| 502 |
+
if details:
|
| 503 |
+
return details
|
| 504 |
+
return {"error": "Episode not found"}
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
# ---------------------------------------------------------------------------
|
| 508 |
+
# Arena endpoints
|
| 509 |
+
# ---------------------------------------------------------------------------
|
| 510 |
+
|
| 511 |
+
@app.post("/arena/run", tags=["arena"])
|
| 512 |
+
async def arena_run(request: Request):
|
| 513 |
+
"""Run agent comparison in the arena."""
|
| 514 |
+
body = {}
|
| 515 |
+
try:
|
| 516 |
+
body = await request.json()
|
| 517 |
+
except Exception:
|
| 518 |
+
pass
|
| 519 |
+
|
| 520 |
+
task_id = body.get("task_id", "basic_flow")
|
| 521 |
+
agents = body.get("agents", None) # List of agent types or None for all
|
| 522 |
+
runs_per_agent = int(body.get("runs_per_agent", 1))
|
| 523 |
+
|
| 524 |
+
arena = get_arena()
|
| 525 |
+
result = await arena.run_comparison(
|
| 526 |
+
task_id=task_id,
|
| 527 |
+
agents=agents,
|
| 528 |
+
runs_per_agent=runs_per_agent,
|
| 529 |
+
)
|
| 530 |
+
return result
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
@app.get("/arena/agents", tags=["arena"])
|
| 534 |
+
async def list_agents():
|
| 535 |
+
"""List available agents in the arena."""
|
| 536 |
+
return {
|
| 537 |
+
"agents": [
|
| 538 |
+
{"id": "llm", "name": "Dynamic LLM Agent", "description": "Makes live LLM API calls for each decision"},
|
| 539 |
+
{"id": "rule_based", "name": "Smart Rule-Based", "description": "Optimized rule-based controller"},
|
| 540 |
+
{"id": "random", "name": "Random Baseline", "description": "Random action selector"},
|
| 541 |
+
{"id": "round_robin", "name": "Round Robin", "description": "Simple alternating controller"},
|
| 542 |
+
]
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
@app.get("/arena/results", tags=["arena"])
|
| 547 |
+
async def arena_results():
|
| 548 |
+
"""Get all historical arena results."""
|
| 549 |
+
arena = get_arena()
|
| 550 |
+
return {
|
| 551 |
+
"total_comparisons": len(arena.results),
|
| 552 |
+
"recent_results": [
|
| 553 |
+
{
|
| 554 |
+
"agent": r.agent_type,
|
| 555 |
+
"episode": r.episode_id,
|
| 556 |
+
"score": r.score,
|
| 557 |
+
"reward": round(r.total_reward, 2),
|
| 558 |
+
"steps": r.steps,
|
| 559 |
+
}
|
| 560 |
+
for r in arena.results[-20:]
|
| 561 |
+
],
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
|
| 565 |
# ---------------------------------------------------------------------------
|
| 566 |
# 3. Gradio UI (mounted at /ui)
|
| 567 |
# ---------------------------------------------------------------------------
|
tasks.py
CHANGED
|
@@ -7,6 +7,15 @@ Defines three tasks of increasing difficulty:
|
|
| 7 |
3. dynamic_scenarios β surge-traffic + emergencies under hard constraints (Hard)
|
| 8 |
|
| 9 |
Each grader returns a GradeResult(score, metrics, feedback) with 0β1 score.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
@@ -77,6 +86,7 @@ def _grade_basic_flow(
|
|
| 77 |
total_vehicles_passed: int,
|
| 78 |
total_waiting_time: float,
|
| 79 |
total_collisions: int,
|
|
|
|
| 80 |
step_count: int,
|
| 81 |
**_ignored,
|
| 82 |
) -> GradeResult:
|
|
@@ -85,21 +95,27 @@ def _grade_basic_flow(
|
|
| 85 |
efficiency_score = 1.0 / (1.0 + total_waiting_time / max(step_count, 1) * 0.1)
|
| 86 |
collision_penalty = 0.8 if total_collisions > 0 else 0.0
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
score = max(0.0, raw - collision_penalty)
|
| 90 |
|
| 91 |
return GradeResult(
|
| 92 |
-
score=_clamp(
|
| 93 |
metrics={
|
| 94 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 95 |
"throughput_score": round(throughput_score, 4),
|
| 96 |
"efficiency_score": round(efficiency_score, 4),
|
|
|
|
| 97 |
"total_collisions": total_collisions,
|
| 98 |
"collision_penalty": collision_penalty,
|
| 99 |
},
|
| 100 |
feedback=(
|
| 101 |
f"Throughput {throughput_per_step:.2f} veh/step "
|
| 102 |
f"(target {_BASIC_FLOW_TARGET_THROUGHPUT_PER_STEP}). "
|
|
|
|
| 103 |
+ ("β Collision penalty applied!" if total_collisions else "No collisions β.")
|
| 104 |
),
|
| 105 |
)
|
|
@@ -139,8 +155,17 @@ def _grade_emergency_priority(
|
|
| 139 |
efficiency_score = 1.0 / (1.0 + total_waiting_time / max(step_count, 1) * 0.05)
|
| 140 |
collision_penalty = 0.85 if total_collisions > 0 else 0.0
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
raw = (throughput_score * 0.30 + em_rate_score * 0.35 +
|
| 143 |
-
delay_score * 0.20 + efficiency_score * 0.15)
|
| 144 |
score = max(0.0, raw - collision_penalty)
|
| 145 |
|
| 146 |
avg_delay_str = (
|
|
@@ -149,13 +174,14 @@ def _grade_emergency_priority(
|
|
| 149 |
)
|
| 150 |
|
| 151 |
return GradeResult(
|
| 152 |
-
score=_clamp(
|
| 153 |
metrics={
|
| 154 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 155 |
"throughput_score": round(throughput_score, 4),
|
| 156 |
"emergency_rate_score": round(em_rate_score, 4),
|
| 157 |
"emergency_delay_score": round(delay_score, 4),
|
| 158 |
"efficiency_score": round(efficiency_score, 4),
|
|
|
|
| 159 |
"total_emergency_passed": total_emergency_passed,
|
| 160 |
"avg_emergency_delay_steps": avg_delay_str,
|
| 161 |
"total_collisions": total_collisions,
|
|
@@ -164,6 +190,7 @@ def _grade_emergency_priority(
|
|
| 164 |
f"Cleared {total_emergency_passed} emergency vehicles "
|
| 165 |
f"(avg delay {avg_delay_str}). "
|
| 166 |
f"Throughput {throughput_per_step:.2f} veh/step. "
|
|
|
|
| 167 |
+ ("β Collision!" if total_collisions else "No collisions β.")
|
| 168 |
),
|
| 169 |
)
|
|
@@ -200,13 +227,20 @@ def _grade_dynamic_scenarios(
|
|
| 200 |
adaptability_score = 1.0 / (1.0 + total_phase_changes / max(step_count, 1) * 0.5)
|
| 201 |
collision_penalty = 0.9 if total_collisions > 0 else 0.0
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
raw = (throughput_score * 0.25 + em_rate_score * 0.30 +
|
| 204 |
delay_score * 0.20 + efficiency_score * 0.15 +
|
| 205 |
-
adaptability_score * 0.10)
|
| 206 |
score = max(0.0, raw - collision_penalty)
|
| 207 |
|
| 208 |
return GradeResult(
|
| 209 |
-
score=_clamp(
|
| 210 |
metrics={
|
| 211 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 212 |
"throughput_score": round(throughput_score, 4),
|
|
@@ -214,6 +248,7 @@ def _grade_dynamic_scenarios(
|
|
| 214 |
"emergency_delay_score": round(delay_score, 4),
|
| 215 |
"efficiency_score": round(efficiency_score, 4),
|
| 216 |
"adaptability_score": round(adaptability_score, 4),
|
|
|
|
| 217 |
"total_collisions": total_collisions,
|
| 218 |
"total_phase_changes": total_phase_changes,
|
| 219 |
},
|
|
@@ -221,6 +256,7 @@ def _grade_dynamic_scenarios(
|
|
| 221 |
f"Dynamic task: throughput {throughput_per_step:.2f} veh/step, "
|
| 222 |
f"{total_emergency_passed} emergencies cleared, "
|
| 223 |
f"{total_phase_changes} phase changes over {step_count} steps. "
|
|
|
|
| 224 |
+ ("β Collision!" if total_collisions else "No collisions β.")
|
| 225 |
),
|
| 226 |
)
|
|
|
|
| 7 |
3. dynamic_scenarios β surge-traffic + emergencies under hard constraints (Hard)
|
| 8 |
|
| 9 |
Each grader returns a GradeResult(score, metrics, feedback) with 0β1 score.
|
| 10 |
+
|
| 11 |
+
Scoring dimensions:
|
| 12 |
+
- Throughput : vehicles cleared per step
|
| 13 |
+
- Efficiency : low total waiting time
|
| 14 |
+
- Emergency rate : emergency vehicles cleared per step
|
| 15 |
+
- Emergency delay : average delay per emergency vehicle
|
| 16 |
+
- Adaptability : not over-switching phases
|
| 17 |
+
- Consistency : steady throughput (low variance) β BONUS
|
| 18 |
+
- Queue balance : not letting one direction starve β BONUS
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
|
|
|
| 86 |
total_vehicles_passed: int,
|
| 87 |
total_waiting_time: float,
|
| 88 |
total_collisions: int,
|
| 89 |
+
total_phase_changes: int,
|
| 90 |
step_count: int,
|
| 91 |
**_ignored,
|
| 92 |
) -> GradeResult:
|
|
|
|
| 95 |
efficiency_score = 1.0 / (1.0 + total_waiting_time / max(step_count, 1) * 0.1)
|
| 96 |
collision_penalty = 0.8 if total_collisions > 0 else 0.0
|
| 97 |
|
| 98 |
+
# BONUS: Queue balance β penalize excessive phase switching (shows instability)
|
| 99 |
+
switch_rate = total_phase_changes / max(step_count, 1)
|
| 100 |
+
stability_bonus = max(0.0, 0.05 * (1.0 - min(switch_rate * 4, 1.0)))
|
| 101 |
+
|
| 102 |
+
raw = throughput_score * 0.6 + efficiency_score * 0.4 + stability_bonus
|
| 103 |
score = max(0.0, raw - collision_penalty)
|
| 104 |
|
| 105 |
return GradeResult(
|
| 106 |
+
score=_clamp(score),
|
| 107 |
metrics={
|
| 108 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 109 |
"throughput_score": round(throughput_score, 4),
|
| 110 |
"efficiency_score": round(efficiency_score, 4),
|
| 111 |
+
"stability_bonus": round(stability_bonus, 4),
|
| 112 |
"total_collisions": total_collisions,
|
| 113 |
"collision_penalty": collision_penalty,
|
| 114 |
},
|
| 115 |
feedback=(
|
| 116 |
f"Throughput {throughput_per_step:.2f} veh/step "
|
| 117 |
f"(target {_BASIC_FLOW_TARGET_THROUGHPUT_PER_STEP}). "
|
| 118 |
+
f"Phase switches: {total_phase_changes} ({switch_rate:.2f}/step). "
|
| 119 |
+ ("β Collision penalty applied!" if total_collisions else "No collisions β.")
|
| 120 |
),
|
| 121 |
)
|
|
|
|
| 155 |
efficiency_score = 1.0 / (1.0 + total_waiting_time / max(step_count, 1) * 0.05)
|
| 156 |
collision_penalty = 0.85 if total_collisions > 0 else 0.0
|
| 157 |
|
| 158 |
+
# BONUS: emergency response quality
|
| 159 |
+
response_bonus = 0.0
|
| 160 |
+
if total_emergency_passed > 0:
|
| 161 |
+
avg_em_delay = total_emergency_delay / total_emergency_passed
|
| 162 |
+
if avg_em_delay < 2.0:
|
| 163 |
+
response_bonus = 0.05 # exceptional response time
|
| 164 |
+
elif avg_em_delay < 4.0:
|
| 165 |
+
response_bonus = 0.02
|
| 166 |
+
|
| 167 |
raw = (throughput_score * 0.30 + em_rate_score * 0.35 +
|
| 168 |
+
delay_score * 0.20 + efficiency_score * 0.15 + response_bonus)
|
| 169 |
score = max(0.0, raw - collision_penalty)
|
| 170 |
|
| 171 |
avg_delay_str = (
|
|
|
|
| 174 |
)
|
| 175 |
|
| 176 |
return GradeResult(
|
| 177 |
+
score=_clamp(score),
|
| 178 |
metrics={
|
| 179 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 180 |
"throughput_score": round(throughput_score, 4),
|
| 181 |
"emergency_rate_score": round(em_rate_score, 4),
|
| 182 |
"emergency_delay_score": round(delay_score, 4),
|
| 183 |
"efficiency_score": round(efficiency_score, 4),
|
| 184 |
+
"response_bonus": round(response_bonus, 4),
|
| 185 |
"total_emergency_passed": total_emergency_passed,
|
| 186 |
"avg_emergency_delay_steps": avg_delay_str,
|
| 187 |
"total_collisions": total_collisions,
|
|
|
|
| 190 |
f"Cleared {total_emergency_passed} emergency vehicles "
|
| 191 |
f"(avg delay {avg_delay_str}). "
|
| 192 |
f"Throughput {throughput_per_step:.2f} veh/step. "
|
| 193 |
+
+ (f"π Fast response bonus +{response_bonus:.0%}! " if response_bonus > 0 else "")
|
| 194 |
+ ("β Collision!" if total_collisions else "No collisions β.")
|
| 195 |
),
|
| 196 |
)
|
|
|
|
| 227 |
adaptability_score = 1.0 / (1.0 + total_phase_changes / max(step_count, 1) * 0.5)
|
| 228 |
collision_penalty = 0.9 if total_collisions > 0 else 0.0
|
| 229 |
|
| 230 |
+
# BONUS: queue balance + surge resilience
|
| 231 |
+
surge_bonus = 0.0
|
| 232 |
+
if total_vehicles_passed > step_count * 1.5:
|
| 233 |
+
surge_bonus = 0.03 # handled high traffic well
|
| 234 |
+
if total_emergency_passed > 0 and total_collisions == 0:
|
| 235 |
+
surge_bonus += 0.02 # survived with zero collisions
|
| 236 |
+
|
| 237 |
raw = (throughput_score * 0.25 + em_rate_score * 0.30 +
|
| 238 |
delay_score * 0.20 + efficiency_score * 0.15 +
|
| 239 |
+
adaptability_score * 0.10 + surge_bonus)
|
| 240 |
score = max(0.0, raw - collision_penalty)
|
| 241 |
|
| 242 |
return GradeResult(
|
| 243 |
+
score=_clamp(score),
|
| 244 |
metrics={
|
| 245 |
"throughput_per_step": round(throughput_per_step, 3),
|
| 246 |
"throughput_score": round(throughput_score, 4),
|
|
|
|
| 248 |
"emergency_delay_score": round(delay_score, 4),
|
| 249 |
"efficiency_score": round(efficiency_score, 4),
|
| 250 |
"adaptability_score": round(adaptability_score, 4),
|
| 251 |
+
"surge_bonus": round(surge_bonus, 4),
|
| 252 |
"total_collisions": total_collisions,
|
| 253 |
"total_phase_changes": total_phase_changes,
|
| 254 |
},
|
|
|
|
| 256 |
f"Dynamic task: throughput {throughput_per_step:.2f} veh/step, "
|
| 257 |
f"{total_emergency_passed} emergencies cleared, "
|
| 258 |
f"{total_phase_changes} phase changes over {step_count} steps. "
|
| 259 |
+
+ (f"π Surge resilience bonus +{surge_bonus:.0%}! " if surge_bonus > 0 else "")
|
| 260 |
+ ("β Collision!" if total_collisions else "No collisions β.")
|
| 261 |
),
|
| 262 |
)
|