amanmurari commited on
Commit
a099b30
Β·
verified Β·
1 Parent(s): 39312c9

Upload folder using huggingface_hub

Browse files
Files changed (9) hide show
  1. analytics.py +215 -0
  2. arena.py +389 -0
  3. dashboard.py +253 -0
  4. environment.py +90 -19
  5. inference.py +275 -143
  6. models.py +18 -0
  7. pyproject.toml +1 -1
  8. server/app.py +454 -0
  9. 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
- - Poisson vehicle arrivals per approach
9
- - Emergency vehicles with urgency levels (0-10)
 
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
- arr = self._cfg["vehicle_arrival_rate"]
254
- em = self._cfg["emergency_arrival_rate"]
255
- urg = self._cfg["emergency_urgency_range"]
256
- surge_p = self._cfg["surge_probability"]
257
- surge_m = self._cfg["surge_multiplier"]
258
- max_q = self._cfg["max_queue_per_lane"]
 
 
 
 
 
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
- n = self._poisson(arr)
 
 
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
- r = vehicles_passed * 0.20
366
- r += emergency_passed * 10.0
367
- r -= waiting_delta * 0.05
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.4
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
- if (len(self._queues[0]) + len(self._queues[1])) == 0:
381
- r -= 0.5
382
  elif p == PHASE_EW_GREEN:
383
- if (len(self._queues[2]) + len(self._queues[3])) == 0:
384
- r -= 0.5
 
 
 
 
 
 
 
 
 
 
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
- API_KEY LiteLLM proxy key (MUST use validator's injected key)
8
 
9
  Optional:
10
  SERVER_URL Running env server (default: http://localhost:8000)
11
 
12
- Usage:
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 β€” read from env at import time (matches sample script pattern)
52
  # ---------------------------------------------------------------------------
53
 
54
- # CRITICAL: Use exact syntax validator requires for static analysis
55
- API_BASE_URL = os.environ["API_BASE_URL"]
56
- API_KEY = os.environ["API_KEY"]
57
- MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4.1-mini")
58
- SERVER_URL = os.environ.get("SERVER_URL", "http://localhost:8000")
 
 
59
 
60
  SEED = 42
61
  MAX_TOKENS = 64
62
  TEMPERATURE = 0.0
63
 
64
  # ---------------------------------------------------------------------------
65
- # Prompts
66
  # ---------------------------------------------------------------------------
67
 
68
  SYSTEM_PROMPT = textwrap.dedent("""
69
- You are an Autonomous Traffic Control AI managing a 4-way intersection.
70
 
71
- OBJECTIVE: Maximise vehicle throughput and prioritise emergency vehicles.
 
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 β€” rarely needed)
 
 
 
 
 
 
 
77
 
78
- DECISION RULES (apply in order):
79
- 1. EMERGENCY CHECK: If emergency vehicles are waiting (emergency_queue > 0),
80
- IMMEDIATELY switch to phase 0 if N/S has emergencies, else phase 1.
81
- Urgency 8-10 is critical - act immediately regardless of time_in_phase.
82
 
83
- 2. MINIMUM PHASE TIME: Stay in current phase at least 3 steps.
84
- If time_in_phase < 3, remain in current phase.
85
 
86
- 3. QUEUE BALANCE: After minimum time, compare N/S vs E/W queue depths.
87
- - If one direction has 3+ more vehicles than the other, switch to that phase.
88
- - If within 2 vehicles, stay in current phase to avoid switch penalty.
89
 
90
- 4. EMPTY QUEUE: If current direction has 0 vehicles waiting and other direction > 0,
91
- switch immediately (no minimum time wait needed).
92
 
93
- REWARD SIGNALS:
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
- OUTPUT: Reply with exactly one JSON object β€” no markdown, no explanation:
 
 
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
- Regular queue : N={q[0]}, S={q[1]}, E={q[2]}, W={q[3]}
 
 
 
 
 
 
 
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
- Respond with exactly: {{"light_phase": <0, 1, or 2>}}
117
  """).strip()
118
 
119
  # ---------------------------------------------------------------------------
120
- # Rule-based fallback β€” optimized for high scores
121
  # ---------------------------------------------------------------------------
122
 
123
- def _rule_based_action(obs: TrafficObservation) -> TrafficAction:
124
- em_q = obs.emergency_queue
125
- em_u = obs.emergency_urgency
126
- q = obs.queue_lengths
127
- current = obs.current_phase
128
- time_in = obs.time_in_phase
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
- # Emergency prioritization: urgency-weighted score per direction
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
- # No emergencies - use queue depth with hysteresis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  ns_total = q[0] + q[1]
140
  ew_total = q[2] + q[3]
 
 
 
141
 
142
- # Dynamic minimum phase time based on queue depth (deeper queues = stay longer)
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
- # LLM action β€” client passed in from main() (created once with env-level vars)
162
- # ---------------------------------------------------------------------------
163
 
164
- def get_llm_action(client: OpenAI, obs: TrafficObservation) -> TrafficAction:
165
- print(f"[DEBUG] Making LLM call to {API_BASE_URL} with model {MODEL_NAME}", flush=True)
166
- resp = client.chat.completions.create(
167
- model=MODEL_NAME,
168
- messages=[
169
- {"role": "system", "content": SYSTEM_PROMPT},
170
- {"role": "user", "content": _build_prompt(obs)},
171
- ],
172
- temperature=TEMPERATURE,
173
- max_tokens=MAX_TOKENS,
174
- stream=False,
175
- )
176
- print(f"[DEBUG] LLM response received", flush=True)
177
- data_str = (resp.choices[0].message.content or "").strip()
178
- match = re.search(r'\{[^}]*\}', data_str.replace('\n', ' '))
179
- data = json.loads(match.group(0) if match else data_str)
180
- phase = max(0, min(2, int(data.get("light_phase", obs.current_phase))))
181
- return TrafficAction(light_phase=phase)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f"[START] task={task_id} env=traffic-control model={MODEL_NAME}", flush=True)
 
 
 
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 = 1
 
223
 
224
  while not step_result.done:
225
- obs = step_result.observation
226
- error_msg: Optional[str] = None
227
-
228
- action = get_llm_action(client, obs)
229
 
230
- action_str = f"TrafficAction(light_phase={action.light_phase})"
 
231
 
232
  try:
233
  step_result = env.step(action)
234
- reward_val = step_result.reward if step_result.reward is not None else 0.0
235
  rewards.append(reward_val)
236
- done_val = str(step_result.done).lower()
237
- error_val = error_msg if error_msg else "null"
 
 
 
 
 
 
 
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).replace('"', "'").replace("\\", "")
 
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
- step += 1
253
-
254
- success = True
255
 
256
  except Exception as exc:
257
- print(f"[STEP] step=0 action=none reward=0.00 done=true error={exc}", flush=True)
 
 
 
 
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={len(rewards)} "
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
- # Debug: show env var status with explicit length check
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
- main()
 
 
 
 
 
 
 
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
- raw = throughput_score * 0.6 + efficiency_score * 0.4
 
 
 
 
89
  score = max(0.0, raw - collision_penalty)
90
 
91
  return GradeResult(
92
- score=_clamp(raw - collision_penalty),
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(raw - collision_penalty),
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(raw - collision_penalty),
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
  )