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.
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)}"
@@ -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
+ )