iflow-mcp_xrds76354_sumo-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iflow_mcp_xrds76354_sumo_mcp-0.1.0.dist-info/METADATA +402 -0
- iflow_mcp_xrds76354_sumo_mcp-0.1.0.dist-info/RECORD +27 -0
- iflow_mcp_xrds76354_sumo_mcp-0.1.0.dist-info/WHEEL +4 -0
- iflow_mcp_xrds76354_sumo_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_xrds76354_sumo_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- mcp_tools/__init__.py +0 -0
- mcp_tools/analysis.py +33 -0
- mcp_tools/network.py +94 -0
- mcp_tools/py.typed +0 -0
- mcp_tools/rl.py +425 -0
- mcp_tools/route.py +91 -0
- mcp_tools/signal.py +96 -0
- mcp_tools/simulation.py +79 -0
- mcp_tools/vehicle.py +52 -0
- resources/__init__.py +0 -0
- server.py +493 -0
- utils/__init__.py +0 -0
- utils/connection.py +145 -0
- utils/output.py +26 -0
- utils/sumo.py +185 -0
- utils/timeout.py +364 -0
- utils/traci.py +82 -0
- workflows/__init__.py +0 -0
- workflows/py.typed +0 -0
- workflows/rl_train.py +34 -0
- workflows/signal_opt.py +210 -0
- workflows/sim_gen.py +70 -0
mcp_tools/rl.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from importlib.util import find_spec
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from utils.traci import ensure_traci_start_stdout_suppressed
|
|
9
|
+
|
|
10
|
+
# NOTE:
|
|
11
|
+
# `sumo_rl` will raise an ImportError at import-time if `SUMO_HOME` is not set.
|
|
12
|
+
# To avoid breaking non-RL features (e.g. importing the MCP server), we lazily
|
|
13
|
+
# import `SumoEnvironment` only when training is actually invoked.
|
|
14
|
+
SumoEnvironment: Any | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_sumo_environment_class() -> Any:
|
|
18
|
+
"""Return `sumo_rl.SumoEnvironment`, importing it lazily."""
|
|
19
|
+
global SumoEnvironment
|
|
20
|
+
if SumoEnvironment is None:
|
|
21
|
+
from sumo_rl import SumoEnvironment as imported_sumo_environment
|
|
22
|
+
|
|
23
|
+
SumoEnvironment = imported_sumo_environment
|
|
24
|
+
return SumoEnvironment
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_sumo_rl_nets_dir() -> Optional[Path]:
|
|
28
|
+
"""Return the `sumo_rl/nets` directory without importing sumo-rl."""
|
|
29
|
+
spec = find_spec("sumo_rl")
|
|
30
|
+
if spec is None or spec.origin is None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
package_dir = Path(spec.origin).resolve().parent
|
|
34
|
+
nets_dir = package_dir / "nets"
|
|
35
|
+
if nets_dir.is_dir():
|
|
36
|
+
return nets_dir
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scenario_candidates(scenario_name: str) -> List[str]:
|
|
41
|
+
"""Return scenario directory name candidates in priority order."""
|
|
42
|
+
raw = scenario_name.strip()
|
|
43
|
+
if not raw:
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
candidates = [raw]
|
|
47
|
+
|
|
48
|
+
normalized = raw.replace("_", "-")
|
|
49
|
+
if normalized != raw:
|
|
50
|
+
candidates.append(normalized)
|
|
51
|
+
|
|
52
|
+
# Backward/variant naming compatibility.
|
|
53
|
+
if raw == "single-intersection":
|
|
54
|
+
candidates.append("2way-single-intersection")
|
|
55
|
+
|
|
56
|
+
# De-duplicate while preserving order.
|
|
57
|
+
seen = set()
|
|
58
|
+
uniq: List[str] = []
|
|
59
|
+
for c in candidates:
|
|
60
|
+
if c not in seen:
|
|
61
|
+
uniq.append(c)
|
|
62
|
+
seen.add(c)
|
|
63
|
+
return uniq
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_rl_scenarios() -> List[str]:
|
|
67
|
+
"""
|
|
68
|
+
List available built-in RL scenarios from sumo-rl package.
|
|
69
|
+
These are typically folders in sumo_rl/nets.
|
|
70
|
+
"""
|
|
71
|
+
nets_dir = _get_sumo_rl_nets_dir()
|
|
72
|
+
if nets_dir is None:
|
|
73
|
+
return ["Error: sumo-rl is not installed or nets directory not found"]
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
scenarios = [p.name for p in nets_dir.iterdir() if p.is_dir()]
|
|
77
|
+
return sorted(scenarios)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return [f"Error listing scenarios: {e}"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_sumo_rl_scenario_files(scenario_name: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
|
83
|
+
"""
|
|
84
|
+
Resolve a sumo-rl built-in scenario directory to its `.net.xml` and `.rou.xml` files.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
(net_file, route_file, error) where error is None on success.
|
|
88
|
+
"""
|
|
89
|
+
nets_dir = _get_sumo_rl_nets_dir()
|
|
90
|
+
if nets_dir is None:
|
|
91
|
+
return None, None, "Error: sumo-rl is not installed or nets directory not found"
|
|
92
|
+
|
|
93
|
+
candidates = _scenario_candidates(scenario_name)
|
|
94
|
+
if not candidates:
|
|
95
|
+
return None, None, "Error: scenario_name is required"
|
|
96
|
+
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
scenario_dir = nets_dir / candidate
|
|
99
|
+
if not scenario_dir.is_dir():
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
net_files = sorted(scenario_dir.glob("*.net.xml"))
|
|
103
|
+
route_files = sorted(scenario_dir.glob("*.rou.xml"))
|
|
104
|
+
|
|
105
|
+
if not net_files or not route_files:
|
|
106
|
+
return None, None, f"Error: Could not find .net.xml or .rou.xml in {scenario_dir}"
|
|
107
|
+
|
|
108
|
+
return str(net_files[0]), str(route_files[0]), None
|
|
109
|
+
|
|
110
|
+
available = [p.name for p in nets_dir.iterdir() if p.is_dir()]
|
|
111
|
+
return (
|
|
112
|
+
None,
|
|
113
|
+
None,
|
|
114
|
+
f"Error: Scenario '{scenario_name}' not found. Available: {sorted(available)}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def create_rl_environment(
|
|
119
|
+
net_file: str,
|
|
120
|
+
route_file: str,
|
|
121
|
+
out_csv_name: Optional[str] = None,
|
|
122
|
+
use_gui: bool = False,
|
|
123
|
+
num_seconds: int = 100000,
|
|
124
|
+
reward_fn: str = 'diff-waiting-time'
|
|
125
|
+
) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Validate and prepare an RL environment configuration.
|
|
128
|
+
Actual environment creation happens in the training process due to Gym's nature.
|
|
129
|
+
This tool validates inputs and returns a configuration summary.
|
|
130
|
+
"""
|
|
131
|
+
if not os.path.exists(net_file):
|
|
132
|
+
return f"Error: Network file not found at {net_file}"
|
|
133
|
+
if not os.path.exists(route_file):
|
|
134
|
+
return f"Error: Route file not found at {route_file}"
|
|
135
|
+
|
|
136
|
+
return (f"RL Environment Configuration Valid:\n"
|
|
137
|
+
f"- Net: {net_file}\n"
|
|
138
|
+
f"- Route: {route_file}\n"
|
|
139
|
+
f"- Reward Function: {reward_fn}\n"
|
|
140
|
+
f"- GUI: {use_gui}\n"
|
|
141
|
+
f"- Horizon: {num_seconds} steps")
|
|
142
|
+
|
|
143
|
+
def run_rl_training(
|
|
144
|
+
net_file: str,
|
|
145
|
+
route_file: str,
|
|
146
|
+
out_dir: str,
|
|
147
|
+
episodes: int = 1,
|
|
148
|
+
steps_per_episode: int = 1000,
|
|
149
|
+
algorithm: str = "ql",
|
|
150
|
+
reward_type: str = "diff-waiting-time"
|
|
151
|
+
) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Run a basic RL training session using Q-Learning (default) or other algorithms.
|
|
154
|
+
This runs synchronously and returns the result.
|
|
155
|
+
"""
|
|
156
|
+
from collections import deque
|
|
157
|
+
|
|
158
|
+
def _tail_file(path: str, max_lines: int = 80) -> Optional[str]:
|
|
159
|
+
try:
|
|
160
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
161
|
+
return "".join(deque(f, maxlen=max_lines)).strip()
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
return None
|
|
164
|
+
except Exception as e:
|
|
165
|
+
return f"<Failed to read {path}: {type(e).__name__}: {e}>"
|
|
166
|
+
|
|
167
|
+
def _append_log_tail(
|
|
168
|
+
diagnostics: list[str],
|
|
169
|
+
label: str,
|
|
170
|
+
path: str,
|
|
171
|
+
max_lines: int = 80,
|
|
172
|
+
) -> None:
|
|
173
|
+
diagnostics.append(f"- {label}: {path}")
|
|
174
|
+
tail = _tail_file(path, max_lines=max_lines)
|
|
175
|
+
if tail:
|
|
176
|
+
diagnostics.append(f"---- {os.path.basename(path)} (tail) ----")
|
|
177
|
+
diagnostics.append(tail)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
if not os.path.exists(net_file):
|
|
181
|
+
return f"Error: Network file not found at {net_file}"
|
|
182
|
+
if not os.path.exists(route_file):
|
|
183
|
+
return f"Error: Route file not found at {route_file}"
|
|
184
|
+
|
|
185
|
+
# Ensure any TraCI-launched SUMO process can't leak stdout into MCP stdio.
|
|
186
|
+
ensure_traci_start_stdout_suppressed()
|
|
187
|
+
|
|
188
|
+
out_dir_abs = os.path.abspath(out_dir)
|
|
189
|
+
os.makedirs(out_dir_abs, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
# Capture SUMO diagnostics into deterministic local files (avoid paths with spaces
|
|
192
|
+
# since sumo-rl splits additional_sumo_cmd by whitespace).
|
|
193
|
+
sumo_error_log_name = "sumo_error.log"
|
|
194
|
+
sumo_error_log_path = os.path.join(out_dir_abs, sumo_error_log_name)
|
|
195
|
+
sumo_log_name = "sumo.log"
|
|
196
|
+
sumo_log_path = os.path.join(out_dir_abs, sumo_log_name)
|
|
197
|
+
sumo_message_log_name = "sumo_message.log"
|
|
198
|
+
sumo_message_log_path = os.path.join(out_dir_abs, sumo_message_log_name)
|
|
199
|
+
|
|
200
|
+
additional_sumo_cmd = (
|
|
201
|
+
f"--error-log {sumo_error_log_name} "
|
|
202
|
+
f"--log {sumo_log_name} "
|
|
203
|
+
f"--message-log {sumo_message_log_name}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
def _pushd(path: str):
|
|
208
|
+
orig_cwd = os.getcwd()
|
|
209
|
+
os.chdir(path)
|
|
210
|
+
try:
|
|
211
|
+
yield
|
|
212
|
+
finally:
|
|
213
|
+
try:
|
|
214
|
+
os.chdir(orig_cwd)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
def _train(
|
|
219
|
+
heartbeat: Callable[[], None],
|
|
220
|
+
cancel_event: threading.Event,
|
|
221
|
+
register_cancel_callback: Callable[[Callable[[], None]], None],
|
|
222
|
+
) -> str:
|
|
223
|
+
env_class = _get_sumo_environment_class()
|
|
224
|
+
env = None
|
|
225
|
+
cancel_message = "Training cancelled: timeout reached, cancellation requested."
|
|
226
|
+
|
|
227
|
+
def _cancel() -> None:
|
|
228
|
+
cancel_event.set()
|
|
229
|
+
if env is None:
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
env.close()
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
register_cancel_callback(_cancel)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
with _pushd(out_dir_abs):
|
|
240
|
+
env = env_class(
|
|
241
|
+
net_file=net_file,
|
|
242
|
+
route_file=route_file,
|
|
243
|
+
out_csv_name=os.path.join(out_dir_abs, "train_results"),
|
|
244
|
+
use_gui=False,
|
|
245
|
+
num_seconds=steps_per_episode,
|
|
246
|
+
reward_fn=reward_type,
|
|
247
|
+
single_agent=False,
|
|
248
|
+
sumo_warnings=False,
|
|
249
|
+
additional_sumo_cmd=additional_sumo_cmd,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if not getattr(env, "ts_ids", None):
|
|
253
|
+
return (
|
|
254
|
+
"Training failed: No traffic lights found in the provided network.\n"
|
|
255
|
+
"Hint: RL training requires a network with traffic lights (tlLogic).\n"
|
|
256
|
+
"If you generated/converted the network yourself, try enabling TLS guessing "
|
|
257
|
+
"(e.g. netgenerate/netconvert with `--tls.guess true`)."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if algorithm != "ql":
|
|
261
|
+
return f"Algorithm {algorithm} not yet implemented in this tool wrapper."
|
|
262
|
+
|
|
263
|
+
# Simple Q-Learning implementation for demonstration.
|
|
264
|
+
# In a real scenario, this would be more complex or use Stable Baselines3.
|
|
265
|
+
from sumo_rl.agents import QLAgent
|
|
266
|
+
|
|
267
|
+
agents: dict[str, QLAgent] = {}
|
|
268
|
+
info_log: list[str] = []
|
|
269
|
+
|
|
270
|
+
for ep in range(1, episodes + 1):
|
|
271
|
+
if cancel_event.is_set():
|
|
272
|
+
return cancel_message
|
|
273
|
+
heartbeat()
|
|
274
|
+
with _pushd(out_dir_abs):
|
|
275
|
+
reset_result = env.reset()
|
|
276
|
+
|
|
277
|
+
if isinstance(reset_result, tuple) and len(reset_result) == 2:
|
|
278
|
+
obs = reset_result[0]
|
|
279
|
+
else:
|
|
280
|
+
obs = reset_result
|
|
281
|
+
|
|
282
|
+
single_agent_mode = False
|
|
283
|
+
if not isinstance(obs, dict):
|
|
284
|
+
single_agent_mode = True
|
|
285
|
+
ts_ids = getattr(env, "ts_ids", None) or ["ts_0"]
|
|
286
|
+
obs = {ts_ids[0]: obs}
|
|
287
|
+
|
|
288
|
+
# Align agent state to the new episode start.
|
|
289
|
+
for ts_id, ts_obs in obs.items():
|
|
290
|
+
state = env.encode(ts_obs, ts_id)
|
|
291
|
+
if ts_id not in agents:
|
|
292
|
+
if single_agent_mode:
|
|
293
|
+
action_space = env.action_space
|
|
294
|
+
state_space = env.observation_space
|
|
295
|
+
else:
|
|
296
|
+
action_space = env.action_spaces(ts_id)
|
|
297
|
+
state_space = env.observation_spaces(ts_id)
|
|
298
|
+
agents[ts_id] = QLAgent(
|
|
299
|
+
starting_state=state,
|
|
300
|
+
state_space=state_space,
|
|
301
|
+
action_space=action_space,
|
|
302
|
+
alpha=0.1,
|
|
303
|
+
gamma=0.99,
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
agent = agents[ts_id]
|
|
307
|
+
if state not in agent.q_table:
|
|
308
|
+
agent.q_table[state] = [0 for _ in range(agent.action_space.n)]
|
|
309
|
+
agent.state = state
|
|
310
|
+
agent.action = None
|
|
311
|
+
agent.acc_reward = 0
|
|
312
|
+
|
|
313
|
+
ep_total_reward = 0.0
|
|
314
|
+
dones: dict[str, bool] = {"__all__": False}
|
|
315
|
+
decision_steps = 0
|
|
316
|
+
delta_time = getattr(env, "delta_time", 1)
|
|
317
|
+
try:
|
|
318
|
+
delta_time_int = int(delta_time)
|
|
319
|
+
except (TypeError, ValueError):
|
|
320
|
+
delta_time_int = 1
|
|
321
|
+
max_decisions = max(1, int(steps_per_episode / max(1, delta_time_int))) + 10
|
|
322
|
+
|
|
323
|
+
done_all = False
|
|
324
|
+
while not done_all and decision_steps < max_decisions:
|
|
325
|
+
if cancel_event.is_set():
|
|
326
|
+
return cancel_message
|
|
327
|
+
heartbeat()
|
|
328
|
+
# sumo-rl returns observations/rewards only for agents that are ready to act.
|
|
329
|
+
if single_agent_mode:
|
|
330
|
+
ts_id = next(iter(obs.keys()), None)
|
|
331
|
+
action = agents[ts_id].act() if ts_id in agents else None
|
|
332
|
+
step_result = env.step(action)
|
|
333
|
+
else:
|
|
334
|
+
actions = {ts_id: agents[ts_id].act() for ts_id in obs.keys() if ts_id in agents}
|
|
335
|
+
step_result = env.step(actions)
|
|
336
|
+
heartbeat()
|
|
337
|
+
if cancel_event.is_set():
|
|
338
|
+
return cancel_message
|
|
339
|
+
|
|
340
|
+
if not isinstance(step_result, tuple):
|
|
341
|
+
return "Training failed: Unexpected return value from sumo-rl step()."
|
|
342
|
+
|
|
343
|
+
if len(step_result) == 4:
|
|
344
|
+
next_obs, rewards, dones, _info = step_result
|
|
345
|
+
if not isinstance(next_obs, dict) or not isinstance(rewards, dict) or not isinstance(dones, dict):
|
|
346
|
+
return "Training failed: Unexpected types returned from sumo-rl step()."
|
|
347
|
+
done_all = bool(dones.get("__all__", False))
|
|
348
|
+
if "__all__" not in dones:
|
|
349
|
+
done_all = all(bool(v) for v in dones.values()) if dones else False
|
|
350
|
+
elif len(step_result) == 5:
|
|
351
|
+
obs_val, reward_val, terminated, truncated, _info = step_result
|
|
352
|
+
ts_ids = getattr(env, "ts_ids", None) or ["ts_0"]
|
|
353
|
+
next_obs = {ts_ids[0]: obs_val}
|
|
354
|
+
rewards = {ts_ids[0]: reward_val}
|
|
355
|
+
done_all = bool(terminated) or bool(truncated)
|
|
356
|
+
dones = {"__all__": done_all, ts_ids[0]: done_all}
|
|
357
|
+
else:
|
|
358
|
+
return (
|
|
359
|
+
"Training failed: Unexpected return value from sumo-rl step(). "
|
|
360
|
+
f"Expected 4-tuple or 5-tuple, got {len(step_result)}."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
for ts_id, reward in rewards.items():
|
|
364
|
+
if ts_id not in agents:
|
|
365
|
+
continue
|
|
366
|
+
if ts_id not in next_obs:
|
|
367
|
+
continue
|
|
368
|
+
agents[ts_id].learn(
|
|
369
|
+
next_state=env.encode(next_obs[ts_id], ts_id),
|
|
370
|
+
reward=reward,
|
|
371
|
+
done=dones.get(ts_id, False),
|
|
372
|
+
)
|
|
373
|
+
ep_total_reward += float(reward)
|
|
374
|
+
|
|
375
|
+
obs = next_obs
|
|
376
|
+
decision_steps += 1
|
|
377
|
+
|
|
378
|
+
info_log.append(f"Episode {ep}/{episodes}: Total Reward = {ep_total_reward:.2f}")
|
|
379
|
+
|
|
380
|
+
# sumo-rl only auto-saves metrics for the previous episode on reset().
|
|
381
|
+
# Save the last episode explicitly.
|
|
382
|
+
env.save_csv(env.out_csv_name, env.episode)
|
|
383
|
+
|
|
384
|
+
return "\n".join(info_log)
|
|
385
|
+
finally:
|
|
386
|
+
if env is not None:
|
|
387
|
+
try:
|
|
388
|
+
env.close()
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
from utils.timeout import run_with_adaptive_timeout
|
|
393
|
+
|
|
394
|
+
return run_with_adaptive_timeout(
|
|
395
|
+
_train,
|
|
396
|
+
operation="rl_training",
|
|
397
|
+
params={"episodes": episodes, "steps_per_episode": steps_per_episode},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
diagnostics: list[str] = [
|
|
402
|
+
f"Training failed: {type(e).__name__}: {e}",
|
|
403
|
+
f"- SUMO_HOME: {os.environ.get('SUMO_HOME', 'Not Set')}",
|
|
404
|
+
f"- sumo_binary: {None}",
|
|
405
|
+
f"- net_file: {net_file}",
|
|
406
|
+
f"- route_file: {route_file}",
|
|
407
|
+
f"- out_dir: {out_dir}",
|
|
408
|
+
f"- additional_sumo_cmd: {additional_sumo_cmd if 'additional_sumo_cmd' in locals() else None}",
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
from utils.sumo import find_sumo_binary
|
|
413
|
+
|
|
414
|
+
diagnostics[2] = f"- sumo_binary: {find_sumo_binary('sumo') or 'Not Found'}"
|
|
415
|
+
except Exception:
|
|
416
|
+
diagnostics.pop(2)
|
|
417
|
+
|
|
418
|
+
if "sumo_error_log_path" in locals():
|
|
419
|
+
_append_log_tail(diagnostics, "sumo_error_log", sumo_error_log_path)
|
|
420
|
+
if "sumo_log_path" in locals():
|
|
421
|
+
_append_log_tail(diagnostics, "sumo_log", sumo_log_path)
|
|
422
|
+
if "sumo_message_log_path" in locals():
|
|
423
|
+
_append_log_tail(diagnostics, "sumo_message_log", sumo_message_log_path)
|
|
424
|
+
|
|
425
|
+
return "\n".join(diagnostics)
|
mcp_tools/route.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sumolib
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
from utils.sumo import build_sumo_diagnostics, find_sumo_tool_script
|
|
8
|
+
from utils.output import truncate_text
|
|
9
|
+
from utils.timeout import subprocess_run_with_timeout
|
|
10
|
+
|
|
11
|
+
def random_trips(net_file: str, output_file: str, end_time: int = 3600, period: float = 1.0, options: Optional[List[str]] = None) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Wrapper for randomTrips.py. Generates random trips for a given network.
|
|
14
|
+
"""
|
|
15
|
+
script = find_sumo_tool_script("randomTrips.py")
|
|
16
|
+
if not script:
|
|
17
|
+
return "\n".join(
|
|
18
|
+
[
|
|
19
|
+
"Error: Could not locate SUMO tool script `randomTrips.py`.",
|
|
20
|
+
build_sumo_diagnostics("sumo"),
|
|
21
|
+
"Please set `SUMO_HOME` to your SUMO installation directory "
|
|
22
|
+
"(so that `$SUMO_HOME/tools/randomTrips.py` exists).",
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Using sys.executable ensures we use the same python environment
|
|
27
|
+
cmd = [sys.executable, script, "-n", net_file, "-o", output_file, "-e", str(end_time), "-p", str(period)]
|
|
28
|
+
|
|
29
|
+
if options:
|
|
30
|
+
cmd.extend(options)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess_run_with_timeout(
|
|
34
|
+
cmd,
|
|
35
|
+
operation="randomTrips",
|
|
36
|
+
params={"end_time": end_time},
|
|
37
|
+
check=True,
|
|
38
|
+
)
|
|
39
|
+
return f"randomTrips successful.\nStdout: {truncate_text(result.stdout)}"
|
|
40
|
+
except subprocess.CalledProcessError as e:
|
|
41
|
+
return f"randomTrips failed.\nStderr: {truncate_text(e.stderr)}\nStdout: {truncate_text(e.stdout)}"
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return f"randomTrips execution error: {str(e)}"
|
|
44
|
+
|
|
45
|
+
def duarouter(net_file: str, route_files: str, output_file: str, options: Optional[List[str]] = None) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Wrapper for duarouter. Computes routes from trips.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
binary = sumolib.checkBinary('duarouter')
|
|
51
|
+
except (SystemExit, Exception) as e:
|
|
52
|
+
return f"Error finding duarouter: {e}"
|
|
53
|
+
|
|
54
|
+
cmd = [binary, "-n", net_file, "--route-files", route_files, "-o", output_file, "--ignore-errors"]
|
|
55
|
+
|
|
56
|
+
if options:
|
|
57
|
+
cmd.extend(options)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
result = subprocess_run_with_timeout(cmd, operation="duarouter", check=True)
|
|
61
|
+
return f"duarouter successful.\nStdout: {truncate_text(result.stdout)}"
|
|
62
|
+
except subprocess.CalledProcessError as e:
|
|
63
|
+
return f"duarouter failed.\nStderr: {truncate_text(e.stderr)}\nStdout: {truncate_text(e.stdout)}"
|
|
64
|
+
except Exception as e:
|
|
65
|
+
return f"duarouter execution error: {str(e)}"
|
|
66
|
+
|
|
67
|
+
def od2trips(od_file: str, output_file: str, options: Optional[List[str]] = None) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Wrapper for od2trips. Converts OD matrices to trips.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
od_file: Path to OD matrix file.
|
|
73
|
+
output_file: Path to output trips file.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
binary = sumolib.checkBinary('od2trips')
|
|
77
|
+
except (SystemExit, Exception) as e:
|
|
78
|
+
return f"Error finding od2trips: {e}"
|
|
79
|
+
|
|
80
|
+
cmd = [binary, "--od-matrix-files", od_file, "-o", output_file]
|
|
81
|
+
|
|
82
|
+
if options:
|
|
83
|
+
cmd.extend(options)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess_run_with_timeout(cmd, operation="od2trips", check=True)
|
|
87
|
+
return f"od2trips successful.\nStdout: {truncate_text(result.stdout)}"
|
|
88
|
+
except subprocess.CalledProcessError as e:
|
|
89
|
+
return f"od2trips failed.\nStderr: {truncate_text(e.stderr)}\nStdout: {truncate_text(e.stdout)}"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return f"od2trips execution error: {str(e)}"
|
mcp_tools/signal.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
|
|
6
|
+
from utils.sumo import build_sumo_diagnostics, find_sumo_tool_script
|
|
7
|
+
from utils.output import truncate_text
|
|
8
|
+
from utils.timeout import subprocess_run_with_timeout
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sum_files_bytes(files_csv: str) -> int:
|
|
12
|
+
total = 0
|
|
13
|
+
for path in files_csv.split(","):
|
|
14
|
+
path = path.strip()
|
|
15
|
+
if not path:
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
total += os.path.getsize(path)
|
|
19
|
+
except OSError:
|
|
20
|
+
continue
|
|
21
|
+
return total
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _file_size_bytes(path: str) -> int:
|
|
25
|
+
try:
|
|
26
|
+
return os.path.getsize(path)
|
|
27
|
+
except OSError:
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
def tls_cycle_adaptation(net_file: str, route_files: str, output_file: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Wrapper for tlsCycleAdaptation.py. Adapts traffic light cycles based on traffic demand.
|
|
33
|
+
"""
|
|
34
|
+
script = find_sumo_tool_script("tlsCycleAdaptation.py")
|
|
35
|
+
if not script:
|
|
36
|
+
return "\n".join(
|
|
37
|
+
[
|
|
38
|
+
"Error: Could not locate SUMO tool script `tlsCycleAdaptation.py`.",
|
|
39
|
+
build_sumo_diagnostics("sumo"),
|
|
40
|
+
"Please set `SUMO_HOME` to your SUMO installation directory "
|
|
41
|
+
"(so that `$SUMO_HOME/tools/tlsCycleAdaptation.py` exists).",
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
cmd = [sys.executable, script, "-n", net_file, "-r", route_files, "-o", output_file]
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess_run_with_timeout(
|
|
49
|
+
cmd,
|
|
50
|
+
operation="tlsCycleAdaptation",
|
|
51
|
+
params={"route_files_bytes": _sum_files_bytes(route_files), "net_file_bytes": _file_size_bytes(net_file)},
|
|
52
|
+
check=True,
|
|
53
|
+
)
|
|
54
|
+
return f"tlsCycleAdaptation successful.\nStdout: {truncate_text(result.stdout)}"
|
|
55
|
+
except subprocess.CalledProcessError as e:
|
|
56
|
+
return f"tlsCycleAdaptation failed.\nStderr: {truncate_text(e.stderr)}\nStdout: {truncate_text(e.stdout)}"
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return f"Error: {str(e)}"
|
|
59
|
+
|
|
60
|
+
def tls_coordinator(net_file: str, route_files: str, output_file: str, options: Optional[List[str]] = None) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Wrapper for tlsCoordinator.py. Optimizes traffic light coordination.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
net_file: Path to network file.
|
|
66
|
+
route_files: Path to route file(s).
|
|
67
|
+
output_file: Path to output network file with coordinated signals.
|
|
68
|
+
"""
|
|
69
|
+
script = find_sumo_tool_script("tlsCoordinator.py")
|
|
70
|
+
if not script:
|
|
71
|
+
return "\n".join(
|
|
72
|
+
[
|
|
73
|
+
"Error: Could not locate SUMO tool script `tlsCoordinator.py`.",
|
|
74
|
+
build_sumo_diagnostics("sumo"),
|
|
75
|
+
"Please set `SUMO_HOME` to your SUMO installation directory "
|
|
76
|
+
"(so that `$SUMO_HOME/tools/tlsCoordinator.py` exists).",
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
cmd = [sys.executable, script, "-n", net_file, "-r", route_files, "-o", output_file]
|
|
81
|
+
|
|
82
|
+
if options:
|
|
83
|
+
cmd.extend(options)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess_run_with_timeout(
|
|
87
|
+
cmd,
|
|
88
|
+
operation="tlsCoordinator",
|
|
89
|
+
params={"route_files_bytes": _sum_files_bytes(route_files), "net_file_bytes": _file_size_bytes(net_file)},
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
return f"tlsCoordinator successful.\nStdout: {truncate_text(result.stdout)}"
|
|
93
|
+
except subprocess.CalledProcessError as e:
|
|
94
|
+
return f"tlsCoordinator failed.\nStderr: {truncate_text(e.stderr)}\nStdout: {truncate_text(e.stdout)}"
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return f"tlsCoordinator execution error: {str(e)}"
|
mcp_tools/simulation.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import subprocess
|
|
4
|
+
import traci
|
|
5
|
+
|
|
6
|
+
from utils.sumo import build_sumo_diagnostics, find_sumo_binary
|
|
7
|
+
from utils.timeout import run_with_adaptive_timeout
|
|
8
|
+
from utils.traci import traci_close_best_effort
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
def run_simple_simulation(config_path: str, steps: int = 100) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Run a SUMO simulation using the given configuration file.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config_path: Path to the .sumocfg file.
|
|
18
|
+
steps: Number of simulation steps to run.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A summary string of the simulation execution.
|
|
22
|
+
"""
|
|
23
|
+
if not os.path.exists(config_path):
|
|
24
|
+
return f"Error: Config file not found at {config_path}"
|
|
25
|
+
|
|
26
|
+
sumo_binary = find_sumo_binary("sumo")
|
|
27
|
+
if not sumo_binary:
|
|
28
|
+
return "\n".join(
|
|
29
|
+
[
|
|
30
|
+
"Error: Could not locate SUMO executable (`sumo`).",
|
|
31
|
+
build_sumo_diagnostics("sumo"),
|
|
32
|
+
"Please ensure SUMO is installed and either `sumo` is available in PATH or `SUMO_HOME` is set.",
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Start simulation
|
|
37
|
+
# We use a random label to allow parallel runs if needed (though traci global lock is an issue)
|
|
38
|
+
# Ideally use libsumo if available for speed, but traci is safer for now.
|
|
39
|
+
cmd = [sumo_binary, "-c", config_path, "--no-step-log", "true", "--random"]
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
def _run() -> str:
|
|
43
|
+
# IMPORTANT: MCP uses stdout for JSON-RPC over stdio.
|
|
44
|
+
# SUMO can write progress/log output to stdout which would corrupt the protocol stream,
|
|
45
|
+
# causing clients to hang or show "undefined" responses.
|
|
46
|
+
traci.start(cmd, stdout=subprocess.DEVNULL)
|
|
47
|
+
|
|
48
|
+
vehicle_counts = []
|
|
49
|
+
for _ in range(steps):
|
|
50
|
+
traci.simulationStep()
|
|
51
|
+
vehicle_counts.append(traci.vehicle.getIDCount())
|
|
52
|
+
|
|
53
|
+
traci.close()
|
|
54
|
+
|
|
55
|
+
avg_vehicles = sum(vehicle_counts) / len(vehicle_counts) if vehicle_counts else 0
|
|
56
|
+
max_vehicles = max(vehicle_counts) if vehicle_counts else 0
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
"Simulation finished successfully.\n"
|
|
60
|
+
f"Steps run: {steps}\n"
|
|
61
|
+
f"Average vehicles: {avg_vehicles:.2f}\n"
|
|
62
|
+
f"Max vehicles: {max_vehicles}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return run_with_adaptive_timeout(_run, operation="simulation", params={"steps": steps})
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
closed = traci_close_best_effort()
|
|
69
|
+
if not closed:
|
|
70
|
+
logger.debug("traci.close timed out during cleanup for %s", config_path)
|
|
71
|
+
return "\n".join(
|
|
72
|
+
[
|
|
73
|
+
f"Simulation error: {type(e).__name__}: {e}",
|
|
74
|
+
f"- config_path: {config_path}",
|
|
75
|
+
f"- steps: {steps}",
|
|
76
|
+
f"- sumo_binary: {sumo_binary}",
|
|
77
|
+
f"- SUMO_HOME: {os.environ.get('SUMO_HOME', 'Not Set')}",
|
|
78
|
+
]
|
|
79
|
+
)
|