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
utils/connection.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Callable, Optional, TypeVar
|
|
6
|
+
|
|
7
|
+
import traci
|
|
8
|
+
|
|
9
|
+
from utils.sumo import find_sumo_binary
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_TRACI_TIMEOUT_S = float(os.environ.get("SUMO_MCP_TRACI_TIMEOUT_S", "10"))
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_with_timeout(func: Callable[[], T], timeout_s: float, description: str) -> T:
|
|
19
|
+
result: dict[str, T] = {}
|
|
20
|
+
error: dict[str, Exception] = {}
|
|
21
|
+
done = threading.Event()
|
|
22
|
+
|
|
23
|
+
def _worker() -> None:
|
|
24
|
+
try:
|
|
25
|
+
result["value"] = func()
|
|
26
|
+
except Exception as exc:
|
|
27
|
+
error["error"] = exc
|
|
28
|
+
finally:
|
|
29
|
+
done.set()
|
|
30
|
+
|
|
31
|
+
thread = threading.Thread(target=_worker, daemon=True, name=f"sumo-mcp:{description}")
|
|
32
|
+
thread.start()
|
|
33
|
+
|
|
34
|
+
if not done.wait(timeout_s):
|
|
35
|
+
raise TimeoutError(f"TimeoutError: {description} timed out after {timeout_s:.1f}s")
|
|
36
|
+
|
|
37
|
+
if "error" in error:
|
|
38
|
+
raise error["error"]
|
|
39
|
+
|
|
40
|
+
return result["value"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SUMOConnection:
|
|
44
|
+
"""
|
|
45
|
+
Singleton class to manage the connection to the SUMO server via TraCI.
|
|
46
|
+
"""
|
|
47
|
+
_instance: Optional['SUMOConnection'] = None
|
|
48
|
+
_connected: bool
|
|
49
|
+
|
|
50
|
+
def __new__(cls) -> "SUMOConnection":
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
cls._instance = super(SUMOConnection, cls).__new__(cls)
|
|
53
|
+
cls._instance._connected = False
|
|
54
|
+
return cls._instance
|
|
55
|
+
|
|
56
|
+
def connect(
|
|
57
|
+
self,
|
|
58
|
+
config_file: Optional[str] = None,
|
|
59
|
+
gui: bool = False,
|
|
60
|
+
port: int = 8813,
|
|
61
|
+
host: str = "localhost",
|
|
62
|
+
timeout_s: float = DEFAULT_TRACI_TIMEOUT_S,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Start SUMO and connect, or connect to an existing instance.
|
|
66
|
+
If config_file is provided, starts a new instance.
|
|
67
|
+
If config_file is None, attempts to connect to existing server at host:port.
|
|
68
|
+
"""
|
|
69
|
+
if self._connected:
|
|
70
|
+
logger.info("Already connected to SUMO.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if config_file:
|
|
75
|
+
binary_name = "sumo-gui" if gui else "sumo"
|
|
76
|
+
binary = find_sumo_binary(binary_name)
|
|
77
|
+
if not binary:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"Could not locate SUMO executable. "
|
|
80
|
+
"Please ensure SUMO is installed and either the binary is in PATH or SUMO_HOME is set."
|
|
81
|
+
)
|
|
82
|
+
# Add --no-step-log to prevent stdout pollution which breaks JSON-RPC
|
|
83
|
+
cmd = [binary, "-c", config_file, "--no-step-log", "true"]
|
|
84
|
+
logger.info(f"Starting SUMO with command: {cmd}")
|
|
85
|
+
_run_with_timeout(
|
|
86
|
+
lambda: traci.start(cmd, port=port, stdout=subprocess.DEVNULL),
|
|
87
|
+
timeout_s=timeout_s,
|
|
88
|
+
description="traci.start",
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
logger.info(f"Connecting to existing SUMO at {host}:{port}")
|
|
92
|
+
_run_with_timeout(
|
|
93
|
+
lambda: traci.init(host=host, port=port),
|
|
94
|
+
timeout_s=timeout_s,
|
|
95
|
+
description="traci.init",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self._connected = True
|
|
99
|
+
logger.info("Successfully connected to SUMO.")
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Failed to connect to SUMO: {e}")
|
|
102
|
+
self._connected = False
|
|
103
|
+
try:
|
|
104
|
+
_run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
def disconnect(self, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> None:
|
|
110
|
+
"""Disconnect from SUMO server."""
|
|
111
|
+
if not self._connected:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
_run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
|
|
116
|
+
logger.info("Disconnected from SUMO.")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Error during disconnect: {e}")
|
|
119
|
+
finally:
|
|
120
|
+
self._connected = False
|
|
121
|
+
|
|
122
|
+
def is_connected(self) -> bool:
|
|
123
|
+
return self._connected
|
|
124
|
+
|
|
125
|
+
def traci_call(self, func: Callable[[], T], description: str, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> T:
|
|
126
|
+
"""Run a TraCI call with a soft timeout and disconnect on timeout."""
|
|
127
|
+
if not self.is_connected():
|
|
128
|
+
raise RuntimeError("Not connected to SUMO.")
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
return _run_with_timeout(func, timeout_s=timeout_s, description=description)
|
|
132
|
+
except TimeoutError:
|
|
133
|
+
self._connected = False
|
|
134
|
+
try:
|
|
135
|
+
_run_with_timeout(traci.close, timeout_s=timeout_s, description="traci.close")
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
def simulation_step(self, step: float = 0, timeout_s: float = DEFAULT_TRACI_TIMEOUT_S) -> None:
|
|
141
|
+
"""Advance the simulation."""
|
|
142
|
+
self.traci_call(lambda: traci.simulationStep(step), description="traci.simulationStep", timeout_s=timeout_s)
|
|
143
|
+
|
|
144
|
+
# Global instance
|
|
145
|
+
connection_manager = SUMOConnection()
|
utils/output.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
DEFAULT_MAX_OUTPUT_CHARS = int(os.environ.get("SUMO_MCP_MAX_OUTPUT_CHARS", "8000"))
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def truncate_text(text: str | None, max_chars: int = DEFAULT_MAX_OUTPUT_CHARS) -> str:
|
|
9
|
+
"""Truncate large stdout/stderr strings to keep MCP responses bounded."""
|
|
10
|
+
if not text:
|
|
11
|
+
return ""
|
|
12
|
+
|
|
13
|
+
if max_chars <= 0:
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
if len(text) <= max_chars:
|
|
17
|
+
return text
|
|
18
|
+
|
|
19
|
+
original_len = len(text)
|
|
20
|
+
tail = text[-max_chars:]
|
|
21
|
+
truncated = original_len - max_chars
|
|
22
|
+
return (
|
|
23
|
+
f"... <truncated {truncated} chars; showing last {max_chars} of {original_len}> ...\n"
|
|
24
|
+
f"{tail}"
|
|
25
|
+
)
|
|
26
|
+
|
utils/sumo.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import sumolib
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_sumo_binary(name: str) -> Optional[str]:
|
|
15
|
+
"""
|
|
16
|
+
Find a SUMO binary by name.
|
|
17
|
+
|
|
18
|
+
Resolution order:
|
|
19
|
+
1) `sumolib.checkBinary()` (respects SUMO_HOME when set)
|
|
20
|
+
2) `shutil.which()` (respects PATH)
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The resolved absolute executable path, or None if it cannot be located.
|
|
24
|
+
"""
|
|
25
|
+
resolved: Optional[str] = None
|
|
26
|
+
try:
|
|
27
|
+
candidate = sumolib.checkBinary(name)
|
|
28
|
+
except (SystemExit, OSError, FileNotFoundError, ImportError) as exc:
|
|
29
|
+
logger.debug("sumolib.checkBinary failed for %s: %s", name, exc)
|
|
30
|
+
candidate = None
|
|
31
|
+
|
|
32
|
+
if candidate and candidate != name:
|
|
33
|
+
resolved = candidate
|
|
34
|
+
|
|
35
|
+
if resolved:
|
|
36
|
+
# Trust sumolib's result if it looks like an absolute path
|
|
37
|
+
# Use os.path.isabs for cross-platform compatibility (handles /usr/bin on Windows)
|
|
38
|
+
if os.path.isabs(resolved):
|
|
39
|
+
return resolved
|
|
40
|
+
return shutil.which(resolved)
|
|
41
|
+
|
|
42
|
+
return shutil.which(name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _candidate_sumo_home_from_binary(sumo_binary: Optional[str]) -> Optional[Path]:
|
|
46
|
+
if not sumo_binary:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
path = Path(sumo_binary)
|
|
50
|
+
if not path.is_absolute():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Typical layout: <SUMO_HOME>/bin/sumo(.exe)
|
|
54
|
+
if path.parent.name.lower() == "bin":
|
|
55
|
+
return path.parent.parent
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_sumo_home() -> Optional[str]:
|
|
60
|
+
"""
|
|
61
|
+
Resolve SUMO_HOME.
|
|
62
|
+
|
|
63
|
+
Priority:
|
|
64
|
+
1) SUMO_HOME environment variable
|
|
65
|
+
2) Derive from `sumo` executable location when it matches <SUMO_HOME>/bin/sumo
|
|
66
|
+
3) Platform-specific common locations
|
|
67
|
+
"""
|
|
68
|
+
env_home = os.environ.get("SUMO_HOME")
|
|
69
|
+
if env_home:
|
|
70
|
+
home = Path(env_home).expanduser()
|
|
71
|
+
if home.exists():
|
|
72
|
+
logger.debug("Resolved SUMO_HOME from env: %s", home)
|
|
73
|
+
return str(home)
|
|
74
|
+
logger.debug("SUMO_HOME env set but path does not exist: %s", home)
|
|
75
|
+
|
|
76
|
+
sumo_binary = find_sumo_binary("sumo")
|
|
77
|
+
candidate = _candidate_sumo_home_from_binary(sumo_binary)
|
|
78
|
+
if candidate and candidate.exists():
|
|
79
|
+
logger.debug("Resolved SUMO_HOME from sumo binary: %s", candidate)
|
|
80
|
+
return str(candidate)
|
|
81
|
+
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
win_paths = [
|
|
84
|
+
Path("C:/Program Files/Eclipse/sumo"),
|
|
85
|
+
Path("C:/Program Files (x86)/Eclipse/sumo"),
|
|
86
|
+
Path("D:/sumo"),
|
|
87
|
+
Path("C:/sumo"),
|
|
88
|
+
]
|
|
89
|
+
for path in win_paths:
|
|
90
|
+
if path.exists() and (path / "tools").exists():
|
|
91
|
+
logger.debug("Resolved SUMO_HOME from Windows common paths: %s", path)
|
|
92
|
+
return str(path)
|
|
93
|
+
|
|
94
|
+
# Windows Registry (optional)
|
|
95
|
+
try:
|
|
96
|
+
import winreg
|
|
97
|
+
|
|
98
|
+
key_path = r"SOFTWARE\Eclipse\SUMO"
|
|
99
|
+
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
|
100
|
+
try:
|
|
101
|
+
key = winreg.OpenKey(hive, key_path)
|
|
102
|
+
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
|
|
103
|
+
winreg.CloseKey(key)
|
|
104
|
+
if not install_path:
|
|
105
|
+
continue
|
|
106
|
+
reg_home = Path(install_path)
|
|
107
|
+
if reg_home.exists() and (reg_home / "tools").exists():
|
|
108
|
+
logger.debug("Resolved SUMO_HOME from Windows Registry: %s", reg_home)
|
|
109
|
+
return str(reg_home)
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
continue
|
|
112
|
+
except OSError:
|
|
113
|
+
continue
|
|
114
|
+
except ImportError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
if sys.platform == "darwin":
|
|
118
|
+
patterns = [
|
|
119
|
+
"/usr/local/Cellar/sumo/*/share/sumo",
|
|
120
|
+
"/opt/homebrew/Cellar/sumo/*/share/sumo",
|
|
121
|
+
]
|
|
122
|
+
matches: list[str] = []
|
|
123
|
+
for pattern in patterns:
|
|
124
|
+
matches.extend(glob.glob(pattern))
|
|
125
|
+
|
|
126
|
+
for raw in sorted(matches, reverse=True):
|
|
127
|
+
home = Path(raw)
|
|
128
|
+
if home.exists() and (home / "tools").exists():
|
|
129
|
+
logger.debug("Resolved SUMO_HOME from Homebrew cellar: %s", home)
|
|
130
|
+
return str(home)
|
|
131
|
+
|
|
132
|
+
linux_home = Path("/usr/share/sumo")
|
|
133
|
+
if linux_home.exists() and (linux_home / "tools").exists():
|
|
134
|
+
logger.debug("Resolved SUMO_HOME from Linux common path: %s", linux_home)
|
|
135
|
+
return str(linux_home)
|
|
136
|
+
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find_sumo_tools_dir() -> Optional[str]:
|
|
141
|
+
"""Return the SUMO tools directory if it can be located."""
|
|
142
|
+
sumo_home = find_sumo_home()
|
|
143
|
+
if not sumo_home:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
tools_dir = Path(sumo_home) / "tools"
|
|
147
|
+
if tools_dir.exists():
|
|
148
|
+
return str(tools_dir)
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find_sumo_tool_script(script_name: str) -> Optional[str]:
|
|
154
|
+
"""Find a SUMO python tool script (e.g. randomTrips.py) under SUMO tools dir."""
|
|
155
|
+
tools_dir = find_sumo_tools_dir()
|
|
156
|
+
if not tools_dir:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
script = Path(tools_dir) / script_name
|
|
160
|
+
if script.exists():
|
|
161
|
+
return str(script)
|
|
162
|
+
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_sumo_diagnostics(binary_name: str = "sumo") -> str:
|
|
167
|
+
"""
|
|
168
|
+
Build a short, multi-line diagnostic string about SUMO discovery.
|
|
169
|
+
|
|
170
|
+
This is intended for inclusion in user-facing error messages.
|
|
171
|
+
"""
|
|
172
|
+
env_home = os.environ.get("SUMO_HOME") or "Not Set"
|
|
173
|
+
which_bin = shutil.which(binary_name) or "Not Found"
|
|
174
|
+
detected_home = find_sumo_home() or "Not Found"
|
|
175
|
+
tools_dir = find_sumo_tools_dir() or "Not Found"
|
|
176
|
+
|
|
177
|
+
return "\n".join(
|
|
178
|
+
[
|
|
179
|
+
"Diagnostics:",
|
|
180
|
+
f" - SUMO_HOME env: {env_home}",
|
|
181
|
+
f" - which({binary_name}): {which_bin}",
|
|
182
|
+
f" - find_sumo_home(): {detected_home}",
|
|
183
|
+
f" - find_sumo_tools_dir(): {tools_dir}",
|
|
184
|
+
]
|
|
185
|
+
)
|