Spaces:
Sleeping
Sleeping
Commit ·
1b72fa2
0
Parent(s):
feat(traffic-rl): build adaptive traffic intelligence system
Browse files- .gitignore +22 -0
- PR_DESCRIPTION.md +15 -0
- README.md +107 -0
- pytest.ini +2 -0
- requirements.txt +4 -0
- run_demo.py +4 -0
- tests/conftest.py +6 -0
- tests/test_agent.py +23 -0
- tests/test_baseline.py +8 -0
- tests/test_env.py +71 -0
- tests/test_multi_agent.py +15 -0
- tests/test_reward.py +70 -0
- tests/test_training_eval.py +43 -0
- traffic_rl/__init__.py +2 -0
- traffic_rl/agent/__init__.py +5 -0
- traffic_rl/agent/dqn_agent.py +102 -0
- traffic_rl/agent/replay_buffer.py +43 -0
- traffic_rl/baseline/__init__.py +5 -0
- traffic_rl/baseline/fixed_time_controller.py +19 -0
- traffic_rl/demo/__init__.py +1 -0
- traffic_rl/demo/run_demo.py +118 -0
- traffic_rl/env/__init__.py +6 -0
- traffic_rl/env/multi_intersection_env.py +58 -0
- traffic_rl/env/traffic_env.py +172 -0
- traffic_rl/evaluation/__init__.py +15 -0
- traffic_rl/evaluation/evaluator.py +130 -0
- traffic_rl/reward/__init__.py +5 -0
- traffic_rl/reward/reward_engine.py +49 -0
- traffic_rl/skills/__init__.py +1 -0
- traffic_rl/skills/baseline_skill.py +7 -0
- traffic_rl/skills/dqn_training_skill.py +9 -0
- traffic_rl/skills/env_builder_skill.py +7 -0
- traffic_rl/skills/evaluation_skill.py +9 -0
- traffic_rl/skills/reward_engineering_skill.py +9 -0
- traffic_rl/skills/visualization_skill.py +12 -0
- traffic_rl/training/__init__.py +5 -0
- traffic_rl/training/trainer.py +90 -0
- traffic_rl/visualization/__init__.py +5 -0
- traffic_rl/visualization/dashboard.py +92 -0
.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / cache
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
|
| 7 |
+
# Environments and local tooling
|
| 8 |
+
.venv/
|
| 9 |
+
.venv_bootstrap/
|
| 10 |
+
.virtualenv_appdata/
|
| 11 |
+
.tmp/
|
| 12 |
+
|
| 13 |
+
# Test/build artifacts
|
| 14 |
+
.pytest_cache/
|
| 15 |
+
.cache/
|
| 16 |
+
|
| 17 |
+
# Output artifacts
|
| 18 |
+
outputs/
|
| 19 |
+
|
| 20 |
+
# IDE
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
PR_DESCRIPTION.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
feat(rl-traffic): Build adaptive RL traffic intelligence with baseline, DQN, and demo pipeline
|
| 2 |
+
|
| 3 |
+
Implemented a full modular RL traffic control system with deterministic OpenEnv-style simulation, dense reward engineering, fixed-time baseline benchmarking, and a production-ready DQN training/evaluation pipeline.
|
| 4 |
+
|
| 5 |
+
The changes were made to provide a complete hackathon-ready project that demonstrates measurable improvement over fixed timing while preserving interpretability and deterministic testability.
|
| 6 |
+
|
| 7 |
+
Alternative considered: integrating external simulators (e.g., SUMO), but this was intentionally avoided to keep execution fast, self-contained, and reproducible for hackathon judging constraints.
|
| 8 |
+
|
| 9 |
+
Includes emergency-priority handling, stochastic + peak/off-peak traffic modeling, bonus multi-intersection support, visualization artifacts, and reusable skill-style modules for environment/reward/training/evaluation/visualization orchestration.
|
| 10 |
+
|
| 11 |
+
Measured on the included demo run:
|
| 12 |
+
- waiting time improvement: 9.82%
|
| 13 |
+
- queue length improvement: 1.64%
|
| 14 |
+
- throughput gain: 7.62%
|
| 15 |
+
- ambulance clearances improved from 1.0 to 2.0 in emergency scenario
|
README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RL-Based Adaptive Traffic Intelligence System
|
| 2 |
+
|
| 3 |
+
A modular, production-style Reinforcement Learning project for adaptive traffic signal control.
|
| 4 |
+
|
| 5 |
+
## Problem Statement
|
| 6 |
+
Traffic control is treated as a sequential decision-making problem. An agent selects signal actions each step to:
|
| 7 |
+
- minimize waiting time
|
| 8 |
+
- minimize queue length
|
| 9 |
+
- maximize throughput
|
| 10 |
+
- prioritize emergency vehicles
|
| 11 |
+
|
| 12 |
+
## Highlights
|
| 13 |
+
- OpenEnv-style deterministic environment (`TrafficEnv`)
|
| 14 |
+
- Dense multi-component reward (no sparse-only objective)
|
| 15 |
+
- Fixed-time baseline controller for mandatory comparison
|
| 16 |
+
- Lightweight PyTorch DQN (replay buffer, epsilon-greedy, target network)
|
| 17 |
+
- Realistic traffic dynamics (stochastic arrivals, lane imbalance, peak/off-peak)
|
| 18 |
+
- Emergency vehicle priority handling
|
| 19 |
+
- Bonus multi-intersection decentralized simulator
|
| 20 |
+
- Visualization + end-to-end demo pipeline
|
| 21 |
+
- Skill-style modular wrappers for orchestration
|
| 22 |
+
|
| 23 |
+
## Architecture
|
| 24 |
+
- `traffic_rl/env`: core environment + multi-intersection extension
|
| 25 |
+
- `traffic_rl/reward`: reward engineering logic
|
| 26 |
+
- `traffic_rl/baseline`: fixed-time control baseline
|
| 27 |
+
- `traffic_rl/agent`: DQN and replay buffer
|
| 28 |
+
- `traffic_rl/training`: training loop
|
| 29 |
+
- `traffic_rl/evaluation`: metrics + policy comparison
|
| 30 |
+
- `traffic_rl/visualization`: dashboard plots
|
| 31 |
+
- `traffic_rl/demo`: demo pipeline runner
|
| 32 |
+
- `traffic_rl/skills`: modular reusable wrappers
|
| 33 |
+
|
| 34 |
+
## State, Action, Reward
|
| 35 |
+
### State
|
| 36 |
+
`[Q1,Q2,Q3,Q4,W1,W2,W3,W4,phase,ambulance_flag]`
|
| 37 |
+
|
| 38 |
+
### Action Space
|
| 39 |
+
- `0`: hold current phase
|
| 40 |
+
- `1`: switch/set NS green phase
|
| 41 |
+
- `2`: switch/set EW green phase
|
| 42 |
+
|
| 43 |
+
### Reward
|
| 44 |
+
`R_total = R_base + flow_bonus - congestion_penalty + emergency_bonus - switch_penalty`
|
| 45 |
+
|
| 46 |
+
Where:
|
| 47 |
+
- `R_base = - (queue_length + waiting_time)`
|
| 48 |
+
- `flow_bonus` rewards throughput
|
| 49 |
+
- `congestion_penalty` penalizes overloaded states
|
| 50 |
+
- `emergency_bonus` rewards ambulance clearance and penalizes ambulance delay
|
| 51 |
+
- `switch_penalty` discourages unstable phase flapping
|
| 52 |
+
|
| 53 |
+
## Quick Start
|
| 54 |
+
Use the project-local virtual environment.
|
| 55 |
+
|
| 56 |
+
```powershell
|
| 57 |
+
# run tests
|
| 58 |
+
.\.venv\Scripts\python.exe -m pytest
|
| 59 |
+
|
| 60 |
+
# run demo pipeline
|
| 61 |
+
.\.venv\Scripts\python.exe run_demo.py --episodes 70 --eval-episodes 20 --output-dir outputs
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Demo Pipeline
|
| 65 |
+
The demo performs:
|
| 66 |
+
1. fixed baseline simulation
|
| 67 |
+
2. RL training
|
| 68 |
+
3. RL evaluation
|
| 69 |
+
4. emergency-priority scenario
|
| 70 |
+
5. metrics + plots export
|
| 71 |
+
|
| 72 |
+
Artifacts are saved to `outputs/`:
|
| 73 |
+
- `metrics_summary.json`
|
| 74 |
+
- `training_history.png`
|
| 75 |
+
- `policy_comparison.png`
|
| 76 |
+
- `fixed_trajectory.png`
|
| 77 |
+
- `rl_trajectory.png`
|
| 78 |
+
|
| 79 |
+
## Measured Results (Latest Run)
|
| 80 |
+
From `outputs/metrics_summary.json`:
|
| 81 |
+
|
| 82 |
+
### Baseline vs RL
|
| 83 |
+
- Waiting time improvement: **9.82%**
|
| 84 |
+
- Queue length improvement: **1.64%**
|
| 85 |
+
- Throughput gain: **7.62%**
|
| 86 |
+
|
| 87 |
+
### Emergency Scenario
|
| 88 |
+
- Baseline ambulance clearances: **1.0**
|
| 89 |
+
- RL ambulance clearances: **2.0**
|
| 90 |
+
|
| 91 |
+
## Skill-Modular Components
|
| 92 |
+
Reusable orchestration wrappers:
|
| 93 |
+
- `env_builder_skill.py`
|
| 94 |
+
- `reward_engineering_skill.py`
|
| 95 |
+
- `baseline_skill.py`
|
| 96 |
+
- `dqn_training_skill.py`
|
| 97 |
+
- `evaluation_skill.py`
|
| 98 |
+
- `visualization_skill.py`
|
| 99 |
+
|
| 100 |
+
## Test Coverage
|
| 101 |
+
TDD tests include:
|
| 102 |
+
- Environment reset/step contracts, deterministic behavior, non-negative state
|
| 103 |
+
- Reward monotonicity and penalty/bonus triggers
|
| 104 |
+
- Baseline phase alternation logic
|
| 105 |
+
- DQN action validity and Q-value output shape
|
| 106 |
+
- Training/evaluation smoke tests
|
| 107 |
+
- Multi-intersection step contract
|
pytest.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
addopts = -q -p no:cacheprovider
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pytest
|
| 2 |
+
numpy
|
| 3 |
+
matplotlib
|
| 4 |
+
torch
|
run_demo.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traffic_rl.demo.run_demo import main
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
main()
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 5 |
+
if str(ROOT) not in sys.path:
|
| 6 |
+
sys.path.insert(0, str(ROOT))
|
tests/test_agent.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from traffic_rl.agent.dqn_agent import DQNAgent
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_dqn_valid_action_output():
|
| 8 |
+
agent = DQNAgent(state_dim=10, action_dim=3, seed=7)
|
| 9 |
+
state = np.zeros(10, dtype=np.float32)
|
| 10 |
+
|
| 11 |
+
action = agent.select_action(state, epsilon=0.0)
|
| 12 |
+
|
| 13 |
+
assert isinstance(action, int)
|
| 14 |
+
assert 0 <= action < 3
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_dqn_q_value_shape():
|
| 18 |
+
agent = DQNAgent(state_dim=10, action_dim=3, seed=7)
|
| 19 |
+
batch = torch.zeros((4, 10), dtype=torch.float32)
|
| 20 |
+
|
| 21 |
+
q_values = agent.q_network(batch)
|
| 22 |
+
|
| 23 |
+
assert tuple(q_values.shape) == (4, 3)
|
tests/test_baseline.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traffic_rl.baseline.fixed_time_controller import FixedTimeController
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_fixed_signal_alternates_correctly():
|
| 5 |
+
controller = FixedTimeController(switch_interval=2)
|
| 6 |
+
phases = [controller.phase_for_step(step) for step in range(8)]
|
| 7 |
+
|
| 8 |
+
assert phases == [0, 0, 1, 1, 0, 0, 1, 1]
|
tests/test_env.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def make_env(**overrides):
|
| 7 |
+
config = {
|
| 8 |
+
"max_steps": 20,
|
| 9 |
+
"arrival_mode": "deterministic",
|
| 10 |
+
"arrival_sequence": [
|
| 11 |
+
[2, 1, 0, 0],
|
| 12 |
+
[1, 0, 2, 0],
|
| 13 |
+
[0, 1, 1, 0],
|
| 14 |
+
[1, 1, 0, 2],
|
| 15 |
+
],
|
| 16 |
+
"service_rate": 2,
|
| 17 |
+
"seed": 123,
|
| 18 |
+
}
|
| 19 |
+
config.update(overrides)
|
| 20 |
+
return TrafficEnv(config=config)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_reset_returns_valid_state():
|
| 24 |
+
env = make_env()
|
| 25 |
+
state = env.reset()
|
| 26 |
+
|
| 27 |
+
assert isinstance(state, np.ndarray)
|
| 28 |
+
assert state.shape == (10,)
|
| 29 |
+
assert np.all(state[:8] >= 0)
|
| 30 |
+
assert state[8] in (0, 1)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_step_updates_queues_and_non_negative():
|
| 34 |
+
env = make_env(arrival_sequence=[[1, 1, 0, 0]], service_rate=1)
|
| 35 |
+
env.reset()
|
| 36 |
+
|
| 37 |
+
next_state, reward, done, info = env.step(1)
|
| 38 |
+
|
| 39 |
+
assert next_state.shape == (10,)
|
| 40 |
+
assert np.all(next_state[:8] >= 0)
|
| 41 |
+
assert isinstance(reward, float)
|
| 42 |
+
assert isinstance(done, bool)
|
| 43 |
+
assert "throughput" in info
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_environment_deterministic_transitions():
|
| 47 |
+
env1 = make_env()
|
| 48 |
+
env2 = make_env()
|
| 49 |
+
|
| 50 |
+
s1 = env1.reset()
|
| 51 |
+
s2 = env2.reset()
|
| 52 |
+
assert np.allclose(s1, s2)
|
| 53 |
+
|
| 54 |
+
actions = [1, 0, 2, 0, 1]
|
| 55 |
+
for a in actions:
|
| 56 |
+
ns1, r1, d1, i1 = env1.step(a)
|
| 57 |
+
ns2, r2, d2, i2 = env2.step(a)
|
| 58 |
+
assert np.allclose(ns1, ns2)
|
| 59 |
+
assert r1 == r2
|
| 60 |
+
assert d1 == d2
|
| 61 |
+
assert i1["throughput"] == i2["throughput"]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_no_negative_values_over_rollout():
|
| 65 |
+
env = make_env()
|
| 66 |
+
env.reset()
|
| 67 |
+
for _ in range(10):
|
| 68 |
+
state, _, done, _ = env.step(0)
|
| 69 |
+
assert np.all(state[:8] >= 0)
|
| 70 |
+
if done:
|
| 71 |
+
break
|
tests/test_multi_agent.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traffic_rl.env.multi_intersection_env import MultiIntersectionEnv
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_multi_intersection_step_contract():
|
| 5 |
+
env = MultiIntersectionEnv(num_intersections=2, env_config={"max_steps": 10, "ambulance_spawn_prob": 0.0})
|
| 6 |
+
|
| 7 |
+
states = env.reset()
|
| 8 |
+
assert len(states) == 2
|
| 9 |
+
assert states[0].shape == (10,)
|
| 10 |
+
|
| 11 |
+
next_states, rewards, dones, infos = env.step([1, 2])
|
| 12 |
+
assert len(next_states) == 2
|
| 13 |
+
assert len(rewards) == 2
|
| 14 |
+
assert len(dones) == 2
|
| 15 |
+
assert isinstance(infos[0], dict)
|
tests/test_reward.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traffic_rl.reward.reward_engine import RewardEngine
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_higher_congestion_yields_lower_reward():
|
| 5 |
+
reward_engine = RewardEngine()
|
| 6 |
+
|
| 7 |
+
low = reward_engine.compute(
|
| 8 |
+
queue_sum=5,
|
| 9 |
+
waiting_sum=8,
|
| 10 |
+
throughput=4,
|
| 11 |
+
switched=False,
|
| 12 |
+
ambulance_wait=0,
|
| 13 |
+
ambulance_cleared=False,
|
| 14 |
+
)
|
| 15 |
+
high = reward_engine.compute(
|
| 16 |
+
queue_sum=20,
|
| 17 |
+
waiting_sum=30,
|
| 18 |
+
throughput=4,
|
| 19 |
+
switched=False,
|
| 20 |
+
ambulance_wait=0,
|
| 21 |
+
ambulance_cleared=False,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
assert high < low
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_ambulance_priority_bonus_applies():
|
| 28 |
+
reward_engine = RewardEngine()
|
| 29 |
+
|
| 30 |
+
no_clear = reward_engine.compute(
|
| 31 |
+
queue_sum=10,
|
| 32 |
+
waiting_sum=10,
|
| 33 |
+
throughput=2,
|
| 34 |
+
switched=False,
|
| 35 |
+
ambulance_wait=3,
|
| 36 |
+
ambulance_cleared=False,
|
| 37 |
+
)
|
| 38 |
+
cleared = reward_engine.compute(
|
| 39 |
+
queue_sum=10,
|
| 40 |
+
waiting_sum=10,
|
| 41 |
+
throughput=2,
|
| 42 |
+
switched=False,
|
| 43 |
+
ambulance_wait=0,
|
| 44 |
+
ambulance_cleared=True,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
assert cleared > no_clear
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_switch_penalty_triggers():
|
| 51 |
+
reward_engine = RewardEngine()
|
| 52 |
+
|
| 53 |
+
keep_phase = reward_engine.compute(
|
| 54 |
+
queue_sum=8,
|
| 55 |
+
waiting_sum=8,
|
| 56 |
+
throughput=3,
|
| 57 |
+
switched=False,
|
| 58 |
+
ambulance_wait=0,
|
| 59 |
+
ambulance_cleared=False,
|
| 60 |
+
)
|
| 61 |
+
switched = reward_engine.compute(
|
| 62 |
+
queue_sum=8,
|
| 63 |
+
waiting_sum=8,
|
| 64 |
+
throughput=3,
|
| 65 |
+
switched=True,
|
| 66 |
+
ambulance_wait=0,
|
| 67 |
+
ambulance_cleared=False,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
assert switched < keep_phase
|
tests/test_training_eval.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 2 |
+
from traffic_rl.training.trainer import TrainingConfig, train_dqn
|
| 3 |
+
from traffic_rl.evaluation.evaluator import evaluate_agent, evaluate_fixed_controller
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_training_loop_returns_history():
|
| 7 |
+
env = TrafficEnv(
|
| 8 |
+
config={
|
| 9 |
+
"max_steps": 20,
|
| 10 |
+
"arrival_mode": "deterministic",
|
| 11 |
+
"arrival_sequence": [[1, 1, 1, 1]],
|
| 12 |
+
"ambulance_spawn_prob": 0.0,
|
| 13 |
+
"seed": 11,
|
| 14 |
+
}
|
| 15 |
+
)
|
| 16 |
+
cfg = TrainingConfig(episodes=4, max_steps=20, batch_size=8, target_sync_interval=2)
|
| 17 |
+
|
| 18 |
+
agent, history = train_dqn(env=env, config=cfg)
|
| 19 |
+
|
| 20 |
+
assert len(history["episode_reward"]) == 4
|
| 21 |
+
assert len(history["avg_queue"]) == 4
|
| 22 |
+
assert agent.action_dim == 3
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_evaluation_returns_metrics():
|
| 26 |
+
env_config = {
|
| 27 |
+
"max_steps": 20,
|
| 28 |
+
"arrival_mode": "deterministic",
|
| 29 |
+
"arrival_sequence": [[2, 1, 2, 1]],
|
| 30 |
+
"ambulance_spawn_prob": 0.0,
|
| 31 |
+
"seed": 12,
|
| 32 |
+
}
|
| 33 |
+
env = TrafficEnv(config=env_config)
|
| 34 |
+
cfg = TrainingConfig(episodes=3, max_steps=20, batch_size=8, target_sync_interval=2)
|
| 35 |
+
agent, _ = train_dqn(env=env, config=cfg)
|
| 36 |
+
|
| 37 |
+
fixed_metrics = evaluate_fixed_controller(env_config=env_config, episodes=2, switch_interval=2)
|
| 38 |
+
rl_metrics = evaluate_agent(agent=agent, env_config=env_config, episodes=2)
|
| 39 |
+
|
| 40 |
+
assert fixed_metrics["avg_waiting_time"] >= 0
|
| 41 |
+
assert rl_metrics["avg_queue_length"] >= 0
|
| 42 |
+
assert "throughput" in fixed_metrics
|
| 43 |
+
assert "throughput" in rl_metrics
|
traffic_rl/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RL-Based Adaptive Traffic Intelligence System."""
|
| 2 |
+
|
traffic_rl/agent/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DQN agent package."""
|
| 2 |
+
|
| 3 |
+
from .dqn_agent import DQNAgent
|
| 4 |
+
|
| 5 |
+
__all__ = ["DQNAgent"]
|
traffic_rl/agent/dqn_agent.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
from torch import nn
|
| 8 |
+
|
| 9 |
+
from .replay_buffer import ReplayBuffer
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class QNetwork(nn.Module):
|
| 13 |
+
def __init__(self, state_dim: int, action_dim: int) -> None:
|
| 14 |
+
super().__init__()
|
| 15 |
+
self.model = nn.Sequential(
|
| 16 |
+
nn.Linear(state_dim, 64),
|
| 17 |
+
nn.ReLU(),
|
| 18 |
+
nn.Linear(64, 64),
|
| 19 |
+
nn.ReLU(),
|
| 20 |
+
nn.Linear(64, action_dim),
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 24 |
+
return self.model(x)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class DQNAgent:
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
state_dim: int,
|
| 31 |
+
action_dim: int,
|
| 32 |
+
learning_rate: float = 1e-3,
|
| 33 |
+
gamma: float = 0.99,
|
| 34 |
+
buffer_size: int = 50000,
|
| 35 |
+
batch_size: int = 64,
|
| 36 |
+
seed: int = 42,
|
| 37 |
+
) -> None:
|
| 38 |
+
self.state_dim = state_dim
|
| 39 |
+
self.action_dim = action_dim
|
| 40 |
+
self.gamma = gamma
|
| 41 |
+
self.batch_size = batch_size
|
| 42 |
+
|
| 43 |
+
random.seed(seed)
|
| 44 |
+
np.random.seed(seed)
|
| 45 |
+
torch.manual_seed(seed)
|
| 46 |
+
|
| 47 |
+
self.q_network = QNetwork(state_dim, action_dim)
|
| 48 |
+
self.target_network = QNetwork(state_dim, action_dim)
|
| 49 |
+
self.target_network.load_state_dict(self.q_network.state_dict())
|
| 50 |
+
self.target_network.eval()
|
| 51 |
+
|
| 52 |
+
self.optimizer = torch.optim.Adam(self.q_network.parameters(), lr=learning_rate)
|
| 53 |
+
self.loss_fn = nn.MSELoss()
|
| 54 |
+
self.replay_buffer = ReplayBuffer(capacity=buffer_size, seed=seed)
|
| 55 |
+
|
| 56 |
+
def select_action(self, state: np.ndarray, epsilon: float = 0.1) -> int:
|
| 57 |
+
if random.random() < epsilon:
|
| 58 |
+
return random.randrange(self.action_dim)
|
| 59 |
+
|
| 60 |
+
state_tensor = torch.as_tensor(state, dtype=torch.float32).unsqueeze(0)
|
| 61 |
+
with torch.no_grad():
|
| 62 |
+
q_values = self.q_network(state_tensor)
|
| 63 |
+
return int(torch.argmax(q_values, dim=1).item())
|
| 64 |
+
|
| 65 |
+
def store_transition(
|
| 66 |
+
self,
|
| 67 |
+
state: np.ndarray,
|
| 68 |
+
action: int,
|
| 69 |
+
reward: float,
|
| 70 |
+
next_state: np.ndarray,
|
| 71 |
+
done: bool,
|
| 72 |
+
) -> None:
|
| 73 |
+
self.replay_buffer.add(state, action, reward, next_state, done)
|
| 74 |
+
|
| 75 |
+
def update(self) -> float | None:
|
| 76 |
+
if len(self.replay_buffer) < self.batch_size:
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
transitions = self.replay_buffer.sample(self.batch_size)
|
| 80 |
+
|
| 81 |
+
states = torch.as_tensor(np.stack([t.state for t in transitions]), dtype=torch.float32)
|
| 82 |
+
actions = torch.as_tensor([t.action for t in transitions], dtype=torch.int64).unsqueeze(1)
|
| 83 |
+
rewards = torch.as_tensor([t.reward for t in transitions], dtype=torch.float32).unsqueeze(1)
|
| 84 |
+
next_states = torch.as_tensor(np.stack([t.next_state for t in transitions]), dtype=torch.float32)
|
| 85 |
+
dones = torch.as_tensor([t.done for t in transitions], dtype=torch.float32).unsqueeze(1)
|
| 86 |
+
|
| 87 |
+
q_values = self.q_network(states).gather(1, actions)
|
| 88 |
+
|
| 89 |
+
with torch.no_grad():
|
| 90 |
+
next_q = self.target_network(next_states).max(dim=1, keepdim=True)[0]
|
| 91 |
+
targets = rewards + (1.0 - dones) * self.gamma * next_q
|
| 92 |
+
|
| 93 |
+
loss = self.loss_fn(q_values, targets)
|
| 94 |
+
|
| 95 |
+
self.optimizer.zero_grad()
|
| 96 |
+
loss.backward()
|
| 97 |
+
self.optimizer.step()
|
| 98 |
+
|
| 99 |
+
return float(loss.item())
|
| 100 |
+
|
| 101 |
+
def sync_target(self) -> None:
|
| 102 |
+
self.target_network.load_state_dict(self.q_network.state_dict())
|
traffic_rl/agent/replay_buffer.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
from collections import deque
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Deque
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Transition:
|
| 13 |
+
state: np.ndarray
|
| 14 |
+
action: int
|
| 15 |
+
reward: float
|
| 16 |
+
next_state: np.ndarray
|
| 17 |
+
done: float
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ReplayBuffer:
|
| 21 |
+
def __init__(self, capacity: int = 10000, seed: int | None = None) -> None:
|
| 22 |
+
if capacity <= 0:
|
| 23 |
+
raise ValueError("capacity must be > 0")
|
| 24 |
+
self.capacity = capacity
|
| 25 |
+
self.buffer: Deque[Transition] = deque(maxlen=capacity)
|
| 26 |
+
self._random = random.Random(seed)
|
| 27 |
+
|
| 28 |
+
def __len__(self) -> int:
|
| 29 |
+
return len(self.buffer)
|
| 30 |
+
|
| 31 |
+
def add(self, state: np.ndarray, action: int, reward: float, next_state: np.ndarray, done: bool) -> None:
|
| 32 |
+
self.buffer.append(
|
| 33 |
+
Transition(
|
| 34 |
+
state=np.asarray(state, dtype=np.float32),
|
| 35 |
+
action=int(action),
|
| 36 |
+
reward=float(reward),
|
| 37 |
+
next_state=np.asarray(next_state, dtype=np.float32),
|
| 38 |
+
done=float(done),
|
| 39 |
+
)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def sample(self, batch_size: int) -> list[Transition]:
|
| 43 |
+
return self._random.sample(list(self.buffer), batch_size)
|
traffic_rl/baseline/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Baseline controllers."""
|
| 2 |
+
|
| 3 |
+
from .fixed_time_controller import FixedTimeController
|
| 4 |
+
|
| 5 |
+
__all__ = ["FixedTimeController"]
|
traffic_rl/baseline/fixed_time_controller.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class FixedTimeController:
|
| 5 |
+
"""Simple fixed-time phase alternator used as benchmark."""
|
| 6 |
+
|
| 7 |
+
def __init__(self, switch_interval: int = 5) -> None:
|
| 8 |
+
if switch_interval <= 0:
|
| 9 |
+
raise ValueError("switch_interval must be positive")
|
| 10 |
+
self.switch_interval = switch_interval
|
| 11 |
+
|
| 12 |
+
def phase_for_step(self, step: int) -> int:
|
| 13 |
+
if step < 0:
|
| 14 |
+
raise ValueError("step must be non-negative")
|
| 15 |
+
return (step // self.switch_interval) % 2
|
| 16 |
+
|
| 17 |
+
def action_for_step(self, step: int) -> int:
|
| 18 |
+
phase = self.phase_for_step(step)
|
| 19 |
+
return 1 if phase == 0 else 2
|
traffic_rl/demo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Demo pipeline package."""
|
traffic_rl/demo/run_demo.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from traffic_rl.baseline.fixed_time_controller import FixedTimeController
|
| 8 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 9 |
+
from traffic_rl.evaluation.evaluator import (
|
| 10 |
+
collect_trajectory,
|
| 11 |
+
compare_policies,
|
| 12 |
+
evaluate_agent,
|
| 13 |
+
evaluate_fixed_controller,
|
| 14 |
+
)
|
| 15 |
+
from traffic_rl.training.trainer import TrainingConfig, train_dqn
|
| 16 |
+
from traffic_rl.visualization.dashboard import plot_comparison, plot_training_history, plot_trajectory
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _print_metrics(title: str, metrics: dict[str, float]) -> None:
|
| 20 |
+
print(f"\n{title}")
|
| 21 |
+
print("-" * len(title))
|
| 22 |
+
for key, value in metrics.items():
|
| 23 |
+
print(f"{key}: {value:.3f}")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main() -> None:
|
| 27 |
+
parser = argparse.ArgumentParser(description="RL-Based Adaptive Traffic Intelligence Demo")
|
| 28 |
+
parser.add_argument("--episodes", type=int, default=80)
|
| 29 |
+
parser.add_argument("--eval-episodes", type=int, default=20)
|
| 30 |
+
parser.add_argument("--output-dir", type=str, default="outputs")
|
| 31 |
+
args = parser.parse_args()
|
| 32 |
+
|
| 33 |
+
output_dir = Path(args.output_dir)
|
| 34 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
env_config = {
|
| 37 |
+
"max_steps": 120,
|
| 38 |
+
"arrival_mode": "stochastic",
|
| 39 |
+
"lane_bias": (1.6, 0.8, 1.4, 0.6),
|
| 40 |
+
"peak_rates": (3.8, 2.2, 3.4, 1.5),
|
| 41 |
+
"offpeak_rates": (1.4, 0.9, 1.2, 0.7),
|
| 42 |
+
"peak_duration": 35,
|
| 43 |
+
"cycle_duration": 60,
|
| 44 |
+
"service_rate": 2,
|
| 45 |
+
"ambulance_spawn_prob": 0.08,
|
| 46 |
+
"seed": 42,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
print("1) Running fixed-time baseline...")
|
| 50 |
+
baseline_metrics = evaluate_fixed_controller(
|
| 51 |
+
env_config=env_config,
|
| 52 |
+
episodes=args.eval_episodes,
|
| 53 |
+
switch_interval=5,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
print("2) Training RL (DQN) agent...")
|
| 57 |
+
train_env = TrafficEnv(config=env_config)
|
| 58 |
+
training_config = TrainingConfig(
|
| 59 |
+
episodes=args.episodes,
|
| 60 |
+
max_steps=env_config["max_steps"],
|
| 61 |
+
batch_size=64,
|
| 62 |
+
target_sync_interval=10,
|
| 63 |
+
epsilon_decay=0.97,
|
| 64 |
+
)
|
| 65 |
+
agent, history = train_dqn(train_env, training_config)
|
| 66 |
+
|
| 67 |
+
print("3) Evaluating RL agent...")
|
| 68 |
+
rl_metrics = evaluate_agent(agent=agent, env_config=env_config, episodes=args.eval_episodes)
|
| 69 |
+
comparison = compare_policies(baseline_metrics, rl_metrics)
|
| 70 |
+
|
| 71 |
+
ambulance_env = {
|
| 72 |
+
**env_config,
|
| 73 |
+
"ambulance_spawn_prob": 0.25,
|
| 74 |
+
"seed": 99,
|
| 75 |
+
}
|
| 76 |
+
baseline_amb = evaluate_fixed_controller(env_config=ambulance_env, episodes=10, switch_interval=5)
|
| 77 |
+
rl_amb = evaluate_agent(agent=agent, env_config=ambulance_env, episodes=10)
|
| 78 |
+
|
| 79 |
+
_print_metrics("Fixed-Time Baseline", baseline_metrics)
|
| 80 |
+
_print_metrics("RL Agent", rl_metrics)
|
| 81 |
+
_print_metrics("Improvement (RL vs Fixed)", comparison)
|
| 82 |
+
|
| 83 |
+
print("\n4) Ambulance Priority Scenario")
|
| 84 |
+
print(f"fixed ambulance_clearances: {baseline_amb['ambulance_clearances']:.3f}")
|
| 85 |
+
print(f"rl ambulance_clearances: {rl_amb['ambulance_clearances']:.3f}")
|
| 86 |
+
|
| 87 |
+
print("\n5) Generating visualizations...")
|
| 88 |
+
saved = {}
|
| 89 |
+
saved.update(plot_training_history(history, output_dir=output_dir))
|
| 90 |
+
saved.update(plot_comparison(baseline_metrics, rl_metrics, output_dir=output_dir))
|
| 91 |
+
|
| 92 |
+
fixed_controller = FixedTimeController(switch_interval=5)
|
| 93 |
+
fixed_trace = collect_trajectory(TrafficEnv(config=env_config), lambda _s, step: fixed_controller.action_for_step(step))
|
| 94 |
+
rl_trace = collect_trajectory(TrafficEnv(config=env_config), lambda state, _step: agent.select_action(state, epsilon=0.0))
|
| 95 |
+
|
| 96 |
+
saved.update(plot_trajectory(fixed_trace, output_dir=output_dir, name="fixed_trajectory"))
|
| 97 |
+
saved.update(plot_trajectory(rl_trace, output_dir=output_dir, name="rl_trajectory"))
|
| 98 |
+
|
| 99 |
+
summary = {
|
| 100 |
+
"baseline": baseline_metrics,
|
| 101 |
+
"rl": rl_metrics,
|
| 102 |
+
"comparison": comparison,
|
| 103 |
+
"ambulance_baseline": baseline_amb,
|
| 104 |
+
"ambulance_rl": rl_amb,
|
| 105 |
+
"artifacts": saved,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
summary_path = output_dir / "metrics_summary.json"
|
| 109 |
+
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
| 110 |
+
|
| 111 |
+
print("\nDemo completed.")
|
| 112 |
+
print(f"Metrics summary: {summary_path}")
|
| 113 |
+
for name, path in saved.items():
|
| 114 |
+
print(f"{name}: {path}")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
main()
|
traffic_rl/env/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment package."""
|
| 2 |
+
|
| 3 |
+
from .multi_intersection_env import MultiIntersectionEnv
|
| 4 |
+
from .traffic_env import TrafficEnv
|
| 5 |
+
|
| 6 |
+
__all__ = ["TrafficEnv", "MultiIntersectionEnv"]
|
traffic_rl/env/multi_intersection_env.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class MultiStepResult:
|
| 12 |
+
states: list[np.ndarray]
|
| 13 |
+
rewards: list[float]
|
| 14 |
+
dones: list[bool]
|
| 15 |
+
infos: list[dict]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MultiIntersectionEnv:
|
| 19 |
+
"""Bonus decentralized multi-intersection simulator."""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
num_intersections: int = 2,
|
| 24 |
+
env_config: dict | None = None,
|
| 25 |
+
transfer_ratio: float = 0.2,
|
| 26 |
+
) -> None:
|
| 27 |
+
if num_intersections <= 0:
|
| 28 |
+
raise ValueError("num_intersections must be > 0")
|
| 29 |
+
|
| 30 |
+
self.transfer_ratio = transfer_ratio
|
| 31 |
+
self.intersections = [TrafficEnv(config=env_config or {}) for _ in range(num_intersections)]
|
| 32 |
+
|
| 33 |
+
def reset(self) -> list[np.ndarray]:
|
| 34 |
+
return [env.reset() for env in self.intersections]
|
| 35 |
+
|
| 36 |
+
def step(self, actions: list[int]) -> tuple[list[np.ndarray], list[float], list[bool], list[dict]]:
|
| 37 |
+
if len(actions) != len(self.intersections):
|
| 38 |
+
raise ValueError("actions length must match number of intersections")
|
| 39 |
+
|
| 40 |
+
states = []
|
| 41 |
+
rewards = []
|
| 42 |
+
dones = []
|
| 43 |
+
infos = []
|
| 44 |
+
|
| 45 |
+
upstream_flow = 0
|
| 46 |
+
for idx, (env, action) in enumerate(zip(self.intersections, actions)):
|
| 47 |
+
if idx > 0 and upstream_flow > 0:
|
| 48 |
+
extra = int(round(upstream_flow * self.transfer_ratio))
|
| 49 |
+
env._apply_arrivals([extra, 0, 0, 0])
|
| 50 |
+
|
| 51 |
+
state, reward, done, info = env.step(action)
|
| 52 |
+
states.append(state)
|
| 53 |
+
rewards.append(float(reward))
|
| 54 |
+
dones.append(bool(done))
|
| 55 |
+
infos.append(info)
|
| 56 |
+
upstream_flow = int(info.get("throughput", 0))
|
| 57 |
+
|
| 58 |
+
return states, rewards, dones, infos
|
traffic_rl/env/traffic_env.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import deque
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from traffic_rl.reward.reward_engine import RewardEngine
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class EnvConfig:
|
| 13 |
+
max_steps: int = 200
|
| 14 |
+
service_rate: int = 2
|
| 15 |
+
arrival_mode: str = "stochastic"
|
| 16 |
+
arrival_sequence: list[list[int]] | None = None
|
| 17 |
+
seed: int = 42
|
| 18 |
+
ambulance_spawn_prob: float = 0.05
|
| 19 |
+
ambulance_lane: int = 0
|
| 20 |
+
lane_bias: tuple[float, float, float, float] = (1.3, 0.8, 1.1, 0.6)
|
| 21 |
+
peak_rates: tuple[float, float, float, float] = (3.2, 2.0, 2.8, 1.4)
|
| 22 |
+
offpeak_rates: tuple[float, float, float, float] = (1.2, 0.8, 1.0, 0.6)
|
| 23 |
+
peak_duration: int = 20
|
| 24 |
+
cycle_duration: int = 40
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TrafficEnv:
|
| 28 |
+
"""Deterministic and fast OpenEnv-style traffic control environment."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, config: dict | None = None, reward_engine: RewardEngine | None = None) -> None:
|
| 31 |
+
raw = config or {}
|
| 32 |
+
self.config = EnvConfig(**{**EnvConfig().__dict__, **raw})
|
| 33 |
+
self.reward_engine = reward_engine or RewardEngine()
|
| 34 |
+
|
| 35 |
+
self.rng = np.random.default_rng(self.config.seed)
|
| 36 |
+
self.step_count = 0
|
| 37 |
+
self.phase = 0 # 0: NS green, 1: EW green
|
| 38 |
+
self.lane_queues: list[deque[tuple[str, int]]] = [deque() for _ in range(4)]
|
| 39 |
+
self.ambulance_active = False
|
| 40 |
+
|
| 41 |
+
def reset(self) -> np.ndarray:
|
| 42 |
+
self.step_count = 0
|
| 43 |
+
self.phase = 0
|
| 44 |
+
self.lane_queues = [deque() for _ in range(4)]
|
| 45 |
+
self.ambulance_active = False
|
| 46 |
+
return self.observation()
|
| 47 |
+
|
| 48 |
+
def observation(self) -> np.ndarray:
|
| 49 |
+
queue_lengths = [len(q) for q in self.lane_queues]
|
| 50 |
+
waiting_loads = [sum(wait for _, wait in q) for q in self.lane_queues]
|
| 51 |
+
ambulance_flag = 1.0 if self.ambulance_active else 0.0
|
| 52 |
+
obs = np.asarray(
|
| 53 |
+
queue_lengths + waiting_loads + [float(self.phase), ambulance_flag],
|
| 54 |
+
dtype=np.float32,
|
| 55 |
+
)
|
| 56 |
+
return obs
|
| 57 |
+
|
| 58 |
+
def compute_reward(
|
| 59 |
+
self,
|
| 60 |
+
*,
|
| 61 |
+
throughput: int,
|
| 62 |
+
switched: bool,
|
| 63 |
+
ambulance_cleared: bool,
|
| 64 |
+
) -> float:
|
| 65 |
+
queue_sum = float(sum(len(q) for q in self.lane_queues))
|
| 66 |
+
waiting_sum = float(sum(sum(wait for _, wait in q) for q in self.lane_queues))
|
| 67 |
+
ambulance_wait = float(self._ambulance_wait())
|
| 68 |
+
return self.reward_engine.compute(
|
| 69 |
+
queue_sum=queue_sum,
|
| 70 |
+
waiting_sum=waiting_sum,
|
| 71 |
+
throughput=float(throughput),
|
| 72 |
+
switched=switched,
|
| 73 |
+
ambulance_wait=ambulance_wait,
|
| 74 |
+
ambulance_cleared=ambulance_cleared,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def step(self, action: int) -> tuple[np.ndarray, float, bool, dict]:
|
| 78 |
+
if action not in (0, 1, 2):
|
| 79 |
+
raise ValueError("action must be 0 (hold), 1 (NS), or 2 (EW)")
|
| 80 |
+
|
| 81 |
+
previous_phase = self.phase
|
| 82 |
+
if action == 1:
|
| 83 |
+
self.phase = 0
|
| 84 |
+
elif action == 2:
|
| 85 |
+
self.phase = 1
|
| 86 |
+
switched = self.phase != previous_phase
|
| 87 |
+
|
| 88 |
+
arrivals = self._next_arrivals()
|
| 89 |
+
self._apply_arrivals(arrivals)
|
| 90 |
+
self._maybe_spawn_ambulance()
|
| 91 |
+
|
| 92 |
+
throughput, ambulance_cleared = self._serve_current_phase()
|
| 93 |
+
self._increment_wait_times()
|
| 94 |
+
|
| 95 |
+
reward = self.compute_reward(
|
| 96 |
+
throughput=throughput,
|
| 97 |
+
switched=switched,
|
| 98 |
+
ambulance_cleared=ambulance_cleared,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
self.step_count += 1
|
| 102 |
+
done = self.step_count >= self.config.max_steps
|
| 103 |
+
|
| 104 |
+
obs = self.observation()
|
| 105 |
+
info = {
|
| 106 |
+
"throughput": throughput,
|
| 107 |
+
"queue_sum": int(sum(obs[:4])),
|
| 108 |
+
"waiting_sum": float(np.sum(obs[4:8])),
|
| 109 |
+
"phase": int(self.phase),
|
| 110 |
+
"switched": switched,
|
| 111 |
+
"ambulance_cleared": ambulance_cleared,
|
| 112 |
+
"arrivals": arrivals,
|
| 113 |
+
}
|
| 114 |
+
return obs, float(reward), bool(done), info
|
| 115 |
+
|
| 116 |
+
def _next_arrivals(self) -> list[int]:
|
| 117 |
+
if self.config.arrival_mode == "deterministic" and self.config.arrival_sequence:
|
| 118 |
+
idx = self.step_count % len(self.config.arrival_sequence)
|
| 119 |
+
vals = self.config.arrival_sequence[idx]
|
| 120 |
+
return [max(0, int(v)) for v in vals]
|
| 121 |
+
|
| 122 |
+
phase_pos = self.step_count % self.config.cycle_duration
|
| 123 |
+
is_peak = phase_pos < self.config.peak_duration
|
| 124 |
+
base = self.config.peak_rates if is_peak else self.config.offpeak_rates
|
| 125 |
+
rates = np.asarray(base, dtype=np.float32) * np.asarray(self.config.lane_bias, dtype=np.float32)
|
| 126 |
+
arrivals = self.rng.poisson(rates).astype(int).tolist()
|
| 127 |
+
return [max(0, int(v)) for v in arrivals]
|
| 128 |
+
|
| 129 |
+
def _apply_arrivals(self, arrivals: list[int]) -> None:
|
| 130 |
+
for lane_idx, count in enumerate(arrivals):
|
| 131 |
+
for _ in range(max(0, count)):
|
| 132 |
+
self.lane_queues[lane_idx].append(("car", 0))
|
| 133 |
+
|
| 134 |
+
def _maybe_spawn_ambulance(self) -> None:
|
| 135 |
+
if self.ambulance_active:
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
if self.rng.random() < self.config.ambulance_spawn_prob:
|
| 139 |
+
lane = int(self.config.ambulance_lane)
|
| 140 |
+
self.lane_queues[lane].append(("ambulance", 0))
|
| 141 |
+
self.ambulance_active = True
|
| 142 |
+
|
| 143 |
+
def _serve_current_phase(self) -> tuple[int, bool]:
|
| 144 |
+
green_lanes = (0, 2) if self.phase == 0 else (1, 3)
|
| 145 |
+
throughput = 0
|
| 146 |
+
ambulance_cleared = False
|
| 147 |
+
|
| 148 |
+
for lane in green_lanes:
|
| 149 |
+
for _ in range(self.config.service_rate):
|
| 150 |
+
if not self.lane_queues[lane]:
|
| 151 |
+
break
|
| 152 |
+
vehicle_type, _wait = self.lane_queues[lane].popleft()
|
| 153 |
+
throughput += 1
|
| 154 |
+
if vehicle_type == "ambulance":
|
| 155 |
+
ambulance_cleared = True
|
| 156 |
+
self.ambulance_active = False
|
| 157 |
+
|
| 158 |
+
return throughput, ambulance_cleared
|
| 159 |
+
|
| 160 |
+
def _increment_wait_times(self) -> None:
|
| 161 |
+
for lane_idx, lane_q in enumerate(self.lane_queues):
|
| 162 |
+
updated = deque((vehicle_type, wait + 1) for vehicle_type, wait in lane_q)
|
| 163 |
+
self.lane_queues[lane_idx] = updated
|
| 164 |
+
|
| 165 |
+
def _ambulance_wait(self) -> int:
|
| 166 |
+
if not self.ambulance_active:
|
| 167 |
+
return 0
|
| 168 |
+
for lane in self.lane_queues:
|
| 169 |
+
for vehicle_type, wait in lane:
|
| 170 |
+
if vehicle_type == "ambulance":
|
| 171 |
+
return wait
|
| 172 |
+
return 0
|
traffic_rl/evaluation/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation and policy comparison utilities."""
|
| 2 |
+
|
| 3 |
+
from .evaluator import (
|
| 4 |
+
collect_trajectory,
|
| 5 |
+
compare_policies,
|
| 6 |
+
evaluate_agent,
|
| 7 |
+
evaluate_fixed_controller,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"collect_trajectory",
|
| 12 |
+
"compare_policies",
|
| 13 |
+
"evaluate_agent",
|
| 14 |
+
"evaluate_fixed_controller",
|
| 15 |
+
]
|
traffic_rl/evaluation/evaluator.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from statistics import mean
|
| 4 |
+
from typing import Callable
|
| 5 |
+
|
| 6 |
+
from traffic_rl.agent.dqn_agent import DQNAgent
|
| 7 |
+
from traffic_rl.baseline.fixed_time_controller import FixedTimeController
|
| 8 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
PolicyFn = Callable[[list[float], int], int]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_episode(env: TrafficEnv, policy: PolicyFn) -> dict[str, float]:
|
| 15 |
+
state = env.reset()
|
| 16 |
+
done = False
|
| 17 |
+
step_idx = 0
|
| 18 |
+
|
| 19 |
+
rewards = []
|
| 20 |
+
queues = []
|
| 21 |
+
waits = []
|
| 22 |
+
throughputs = []
|
| 23 |
+
ambulance_cleared_count = 0
|
| 24 |
+
|
| 25 |
+
while not done:
|
| 26 |
+
action = policy(state.tolist(), step_idx)
|
| 27 |
+
state, reward, done, info = env.step(action)
|
| 28 |
+
|
| 29 |
+
rewards.append(float(reward))
|
| 30 |
+
queues.append(float(info["queue_sum"]))
|
| 31 |
+
waits.append(float(info["waiting_sum"]))
|
| 32 |
+
throughputs.append(float(info["throughput"]))
|
| 33 |
+
ambulance_cleared_count += int(bool(info["ambulance_cleared"]))
|
| 34 |
+
step_idx += 1
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
"reward": float(sum(rewards)),
|
| 38 |
+
"avg_queue_length": float(mean(queues) if queues else 0.0),
|
| 39 |
+
"avg_waiting_time": float(mean(waits) if waits else 0.0),
|
| 40 |
+
"throughput": float(sum(throughputs)),
|
| 41 |
+
"ambulance_clearances": float(ambulance_cleared_count),
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def evaluate_fixed_controller(
|
| 46 |
+
*,
|
| 47 |
+
env_config: dict,
|
| 48 |
+
episodes: int = 20,
|
| 49 |
+
switch_interval: int = 5,
|
| 50 |
+
) -> dict[str, float]:
|
| 51 |
+
controller = FixedTimeController(switch_interval=switch_interval)
|
| 52 |
+
|
| 53 |
+
def policy(_state: list[float], step: int) -> int:
|
| 54 |
+
return controller.action_for_step(step)
|
| 55 |
+
|
| 56 |
+
episode_metrics = []
|
| 57 |
+
for _ in range(episodes):
|
| 58 |
+
env = TrafficEnv(config=env_config)
|
| 59 |
+
episode_metrics.append(run_episode(env, policy))
|
| 60 |
+
|
| 61 |
+
return _aggregate(episode_metrics)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def evaluate_agent(
|
| 65 |
+
*,
|
| 66 |
+
agent: DQNAgent,
|
| 67 |
+
env_config: dict,
|
| 68 |
+
episodes: int = 20,
|
| 69 |
+
) -> dict[str, float]:
|
| 70 |
+
def policy(state: list[float], _step: int) -> int:
|
| 71 |
+
return agent.select_action(state, epsilon=0.0)
|
| 72 |
+
|
| 73 |
+
episode_metrics = []
|
| 74 |
+
for _ in range(episodes):
|
| 75 |
+
env = TrafficEnv(config=env_config)
|
| 76 |
+
episode_metrics.append(run_episode(env, policy))
|
| 77 |
+
|
| 78 |
+
return _aggregate(episode_metrics)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _aggregate(episode_metrics: list[dict[str, float]]) -> dict[str, float]:
|
| 82 |
+
keys = episode_metrics[0].keys()
|
| 83 |
+
return {k: float(mean(m[k] for m in episode_metrics)) for k in keys}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def compare_policies(baseline: dict[str, float], rl: dict[str, float]) -> dict[str, float]:
|
| 87 |
+
def pct_improve(lower_is_better_key: str) -> float:
|
| 88 |
+
b = baseline[lower_is_better_key]
|
| 89 |
+
r = rl[lower_is_better_key]
|
| 90 |
+
if b == 0:
|
| 91 |
+
return 0.0
|
| 92 |
+
return float(((b - r) / b) * 100.0)
|
| 93 |
+
|
| 94 |
+
def pct_gain(higher_is_better_key: str) -> float:
|
| 95 |
+
b = baseline[higher_is_better_key]
|
| 96 |
+
r = rl[higher_is_better_key]
|
| 97 |
+
if b == 0:
|
| 98 |
+
return 0.0
|
| 99 |
+
return float(((r - b) / b) * 100.0)
|
| 100 |
+
|
| 101 |
+
return {
|
| 102 |
+
"waiting_time_improvement_pct": pct_improve("avg_waiting_time"),
|
| 103 |
+
"queue_length_improvement_pct": pct_improve("avg_queue_length"),
|
| 104 |
+
"throughput_gain_pct": pct_gain("throughput"),
|
| 105 |
+
"ambulance_clearance_gain_pct": pct_gain("ambulance_clearances"),
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def collect_trajectory(env: TrafficEnv, policy: PolicyFn) -> dict[str, list[float]]:
|
| 110 |
+
state = env.reset()
|
| 111 |
+
done = False
|
| 112 |
+
step_idx = 0
|
| 113 |
+
|
| 114 |
+
trace = {
|
| 115 |
+
"reward": [],
|
| 116 |
+
"queue_sum": [],
|
| 117 |
+
"waiting_sum": [],
|
| 118 |
+
"phase": [],
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
while not done:
|
| 122 |
+
action = policy(state.tolist(), step_idx)
|
| 123 |
+
state, reward, done, info = env.step(action)
|
| 124 |
+
trace["reward"].append(float(reward))
|
| 125 |
+
trace["queue_sum"].append(float(info["queue_sum"]))
|
| 126 |
+
trace["waiting_sum"].append(float(info["waiting_sum"]))
|
| 127 |
+
trace["phase"].append(float(info["phase"]))
|
| 128 |
+
step_idx += 1
|
| 129 |
+
|
| 130 |
+
return trace
|
traffic_rl/reward/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reward system package."""
|
| 2 |
+
|
| 3 |
+
from .reward_engine import RewardEngine, RewardWeights
|
| 4 |
+
|
| 5 |
+
__all__ = ["RewardEngine", "RewardWeights"]
|
traffic_rl/reward/reward_engine.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass(frozen=True)
|
| 7 |
+
class RewardWeights:
|
| 8 |
+
queue_weight: float = 1.0
|
| 9 |
+
waiting_weight: float = 0.35
|
| 10 |
+
flow_weight: float = 2.0
|
| 11 |
+
congestion_weight: float = 0.5
|
| 12 |
+
emergency_bonus: float = 40.0
|
| 13 |
+
ambulance_wait_penalty: float = 3.0
|
| 14 |
+
switch_penalty: float = 1.5
|
| 15 |
+
congestion_threshold: float = 25.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class RewardEngine:
|
| 19 |
+
"""Dense multi-objective reward for adaptive signal control."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, weights: RewardWeights | None = None) -> None:
|
| 22 |
+
self.weights = weights or RewardWeights()
|
| 23 |
+
|
| 24 |
+
def compute(
|
| 25 |
+
self,
|
| 26 |
+
*,
|
| 27 |
+
queue_sum: float,
|
| 28 |
+
waiting_sum: float,
|
| 29 |
+
throughput: float,
|
| 30 |
+
switched: bool,
|
| 31 |
+
ambulance_wait: float,
|
| 32 |
+
ambulance_cleared: bool,
|
| 33 |
+
) -> float:
|
| 34 |
+
w = self.weights
|
| 35 |
+
|
| 36 |
+
base_cost = (w.queue_weight * queue_sum) + (w.waiting_weight * waiting_sum)
|
| 37 |
+
base_reward = -base_cost
|
| 38 |
+
|
| 39 |
+
flow_bonus = w.flow_weight * throughput
|
| 40 |
+
congestion_penalty = w.congestion_weight * max(queue_sum - w.congestion_threshold, 0.0)
|
| 41 |
+
|
| 42 |
+
emergency_term = -w.ambulance_wait_penalty * ambulance_wait
|
| 43 |
+
if ambulance_cleared:
|
| 44 |
+
emergency_term += w.emergency_bonus
|
| 45 |
+
|
| 46 |
+
switch_term = -w.switch_penalty if switched else 0.0
|
| 47 |
+
|
| 48 |
+
total_reward = base_reward + flow_bonus - congestion_penalty + emergency_term + switch_term
|
| 49 |
+
return float(total_reward)
|
traffic_rl/skills/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Skill wrappers for modular orchestration."""
|
traffic_rl/skills/baseline_skill.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.evaluation.evaluator import evaluate_fixed_controller
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def run_fixed_baseline(env_config: dict, episodes: int = 20, switch_interval: int = 5) -> dict[str, float]:
|
| 7 |
+
return evaluate_fixed_controller(env_config=env_config, episodes=episodes, switch_interval=switch_interval)
|
traffic_rl/skills/dqn_training_skill.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 4 |
+
from traffic_rl.training.trainer import TrainingConfig, train_dqn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def train_agent(env_config: dict, config: TrainingConfig | None = None):
|
| 8 |
+
env = TrafficEnv(config=env_config)
|
| 9 |
+
return train_dqn(env=env, config=config)
|
traffic_rl/skills/env_builder_skill.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def build_environment(config: dict | None = None) -> TrafficEnv:
|
| 7 |
+
return TrafficEnv(config=config or {})
|
traffic_rl/skills/evaluation_skill.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.evaluation.evaluator import compare_policies, evaluate_agent
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def evaluate_system(agent, env_config: dict, baseline_metrics: dict, episodes: int = 20) -> dict:
|
| 7 |
+
rl_metrics = evaluate_agent(agent=agent, env_config=env_config, episodes=episodes)
|
| 8 |
+
comparison = compare_policies(baseline_metrics, rl_metrics)
|
| 9 |
+
return {"rl": rl_metrics, "comparison": comparison}
|
traffic_rl/skills/reward_engineering_skill.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.reward.reward_engine import RewardEngine, RewardWeights
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def build_reward_engine(weights: dict | None = None) -> RewardEngine:
|
| 7 |
+
if not weights:
|
| 8 |
+
return RewardEngine()
|
| 9 |
+
return RewardEngine(weights=RewardWeights(**weights))
|
traffic_rl/skills/visualization_skill.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from traffic_rl.visualization.dashboard import plot_comparison, plot_training_history, plot_trajectory
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def render_dashboard(history: dict, baseline: dict, rl: dict, fixed_trace: dict, rl_trace: dict, output_dir: str = "outputs") -> dict[str, str]:
|
| 7 |
+
saved = {}
|
| 8 |
+
saved.update(plot_training_history(history, output_dir=output_dir))
|
| 9 |
+
saved.update(plot_comparison(baseline, rl, output_dir=output_dir))
|
| 10 |
+
saved.update(plot_trajectory(fixed_trace, output_dir=output_dir, name="fixed_trajectory"))
|
| 11 |
+
saved.update(plot_trajectory(rl_trace, output_dir=output_dir, name="rl_trajectory"))
|
| 12 |
+
return saved
|
traffic_rl/training/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training workflows for RL traffic control."""
|
| 2 |
+
|
| 3 |
+
from .trainer import TrainingConfig, train_dqn
|
| 4 |
+
|
| 5 |
+
__all__ = ["TrainingConfig", "train_dqn"]
|
traffic_rl/training/trainer.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from traffic_rl.agent.dqn_agent import DQNAgent
|
| 8 |
+
from traffic_rl.env.traffic_env import TrafficEnv
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class TrainingConfig:
|
| 13 |
+
episodes: int = 120
|
| 14 |
+
max_steps: int = 120
|
| 15 |
+
gamma: float = 0.99
|
| 16 |
+
learning_rate: float = 1e-3
|
| 17 |
+
epsilon_start: float = 1.0
|
| 18 |
+
epsilon_end: float = 0.05
|
| 19 |
+
epsilon_decay: float = 0.98
|
| 20 |
+
batch_size: int = 64
|
| 21 |
+
buffer_size: int = 50000
|
| 22 |
+
target_sync_interval: int = 10
|
| 23 |
+
seed: int = 42
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _epsilon_for_episode(config: TrainingConfig, episode_idx: int) -> float:
|
| 27 |
+
decayed = config.epsilon_start * (config.epsilon_decay**episode_idx)
|
| 28 |
+
return float(max(config.epsilon_end, decayed))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def train_dqn(env: TrafficEnv, config: TrainingConfig | None = None) -> tuple[DQNAgent, dict[str, list[float]]]:
|
| 32 |
+
cfg = config or TrainingConfig()
|
| 33 |
+
|
| 34 |
+
agent = DQNAgent(
|
| 35 |
+
state_dim=10,
|
| 36 |
+
action_dim=3,
|
| 37 |
+
learning_rate=cfg.learning_rate,
|
| 38 |
+
gamma=cfg.gamma,
|
| 39 |
+
buffer_size=cfg.buffer_size,
|
| 40 |
+
batch_size=cfg.batch_size,
|
| 41 |
+
seed=cfg.seed,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
history: dict[str, list[float]] = {
|
| 45 |
+
"episode_reward": [],
|
| 46 |
+
"avg_queue": [],
|
| 47 |
+
"avg_wait": [],
|
| 48 |
+
"throughput": [],
|
| 49 |
+
"loss": [],
|
| 50 |
+
"epsilon": [],
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
for episode in range(cfg.episodes):
|
| 54 |
+
state = env.reset()
|
| 55 |
+
epsilon = _epsilon_for_episode(cfg, episode)
|
| 56 |
+
|
| 57 |
+
total_reward = 0.0
|
| 58 |
+
step_queue = []
|
| 59 |
+
step_wait = []
|
| 60 |
+
total_throughput = 0
|
| 61 |
+
|
| 62 |
+
for _step in range(min(cfg.max_steps, env.config.max_steps)):
|
| 63 |
+
action = agent.select_action(state, epsilon=epsilon)
|
| 64 |
+
next_state, reward, done, info = env.step(action)
|
| 65 |
+
|
| 66 |
+
agent.store_transition(state, action, reward, next_state, done)
|
| 67 |
+
loss = agent.update()
|
| 68 |
+
|
| 69 |
+
if loss is not None:
|
| 70 |
+
history["loss"].append(loss)
|
| 71 |
+
|
| 72 |
+
total_reward += reward
|
| 73 |
+
step_queue.append(float(info["queue_sum"]))
|
| 74 |
+
step_wait.append(float(info["waiting_sum"]))
|
| 75 |
+
total_throughput += int(info["throughput"])
|
| 76 |
+
|
| 77 |
+
state = next_state
|
| 78 |
+
if done:
|
| 79 |
+
break
|
| 80 |
+
|
| 81 |
+
if (episode + 1) % cfg.target_sync_interval == 0:
|
| 82 |
+
agent.sync_target()
|
| 83 |
+
|
| 84 |
+
history["episode_reward"].append(float(total_reward))
|
| 85 |
+
history["avg_queue"].append(float(np.mean(step_queue) if step_queue else 0.0))
|
| 86 |
+
history["avg_wait"].append(float(np.mean(step_wait) if step_wait else 0.0))
|
| 87 |
+
history["throughput"].append(float(total_throughput))
|
| 88 |
+
history["epsilon"].append(epsilon)
|
| 89 |
+
|
| 90 |
+
return agent, history
|
traffic_rl/visualization/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualization helpers."""
|
| 2 |
+
|
| 3 |
+
from .dashboard import plot_comparison, plot_training_history, plot_trajectory
|
| 4 |
+
|
| 5 |
+
__all__ = ["plot_training_history", "plot_comparison", "plot_trajectory"]
|
traffic_rl/visualization/dashboard.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import matplotlib
|
| 6 |
+
|
| 7 |
+
matplotlib.use("Agg")
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _ensure_dir(path: str | Path) -> Path:
|
| 12 |
+
p = Path(path)
|
| 13 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
return p
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def plot_training_history(history: dict[str, list[float]], output_dir: str | Path = "outputs") -> dict[str, str]:
|
| 18 |
+
out = _ensure_dir(output_dir)
|
| 19 |
+
|
| 20 |
+
fig, axes = plt.subplots(2, 2, figsize=(12, 8), constrained_layout=True)
|
| 21 |
+
|
| 22 |
+
axes[0, 0].plot(history.get("episode_reward", []), color="#1f77b4")
|
| 23 |
+
axes[0, 0].set_title("Episode Reward")
|
| 24 |
+
axes[0, 0].set_xlabel("Episode")
|
| 25 |
+
|
| 26 |
+
axes[0, 1].plot(history.get("avg_queue", []), color="#d62728")
|
| 27 |
+
axes[0, 1].set_title("Average Queue Length")
|
| 28 |
+
axes[0, 1].set_xlabel("Episode")
|
| 29 |
+
|
| 30 |
+
axes[1, 0].plot(history.get("throughput", []), color="#2ca02c")
|
| 31 |
+
axes[1, 0].set_title("Throughput")
|
| 32 |
+
axes[1, 0].set_xlabel("Episode")
|
| 33 |
+
|
| 34 |
+
axes[1, 1].plot(history.get("epsilon", []), color="#9467bd")
|
| 35 |
+
axes[1, 1].set_title("Exploration (Epsilon)")
|
| 36 |
+
axes[1, 1].set_xlabel("Episode")
|
| 37 |
+
|
| 38 |
+
path = out / "training_history.png"
|
| 39 |
+
fig.savefig(path, dpi=160)
|
| 40 |
+
plt.close(fig)
|
| 41 |
+
|
| 42 |
+
return {"training_history": str(path)}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def plot_comparison(
|
| 46 |
+
baseline: dict[str, float],
|
| 47 |
+
rl: dict[str, float],
|
| 48 |
+
output_dir: str | Path = "outputs",
|
| 49 |
+
) -> dict[str, str]:
|
| 50 |
+
out = _ensure_dir(output_dir)
|
| 51 |
+
|
| 52 |
+
metrics = ["avg_waiting_time", "avg_queue_length", "throughput", "ambulance_clearances"]
|
| 53 |
+
labels = ["Avg Wait", "Avg Queue", "Throughput", "Ambulance Clears"]
|
| 54 |
+
|
| 55 |
+
x = range(len(metrics))
|
| 56 |
+
width = 0.35
|
| 57 |
+
|
| 58 |
+
fig, ax = plt.subplots(figsize=(10, 5), constrained_layout=True)
|
| 59 |
+
ax.bar([i - width / 2 for i in x], [baseline[m] for m in metrics], width, label="Fixed")
|
| 60 |
+
ax.bar([i + width / 2 for i in x], [rl[m] for m in metrics], width, label="RL")
|
| 61 |
+
ax.set_xticks(list(x))
|
| 62 |
+
ax.set_xticklabels(labels)
|
| 63 |
+
ax.set_title("RL vs Fixed-Time Controller")
|
| 64 |
+
ax.legend()
|
| 65 |
+
|
| 66 |
+
path = out / "policy_comparison.png"
|
| 67 |
+
fig.savefig(path, dpi=160)
|
| 68 |
+
plt.close(fig)
|
| 69 |
+
|
| 70 |
+
return {"policy_comparison": str(path)}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def plot_trajectory(trace: dict[str, list[float]], output_dir: str | Path = "outputs", name: str = "trajectory") -> dict[str, str]:
|
| 74 |
+
out = _ensure_dir(output_dir)
|
| 75 |
+
|
| 76 |
+
fig, axes = plt.subplots(3, 1, figsize=(10, 8), constrained_layout=True)
|
| 77 |
+
|
| 78 |
+
axes[0].plot(trace.get("queue_sum", []), color="#ff7f0e")
|
| 79 |
+
axes[0].set_title("Queue Sum")
|
| 80 |
+
|
| 81 |
+
axes[1].plot(trace.get("phase", []), color="#17becf")
|
| 82 |
+
axes[1].set_title("Signal Phase")
|
| 83 |
+
|
| 84 |
+
axes[2].plot(trace.get("reward", []), color="#1f77b4")
|
| 85 |
+
axes[2].set_title("Reward")
|
| 86 |
+
axes[2].set_xlabel("Step")
|
| 87 |
+
|
| 88 |
+
path = out / f"{name}.png"
|
| 89 |
+
fig.savefig(path, dpi=160)
|
| 90 |
+
plt.close(fig)
|
| 91 |
+
|
| 92 |
+
return {name: str(path)}
|