navigation-manager 0.3.1__tar.gz
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.
- navigation_manager-0.3.1/PKG-INFO +39 -0
- navigation_manager-0.3.1/README.md +29 -0
- navigation_manager-0.3.1/navigation_manager/__init__.py +0 -0
- navigation_manager-0.3.1/navigation_manager/__main__.py +97 -0
- navigation_manager-0.3.1/navigation_manager/apps.py +172 -0
- navigation_manager-0.3.1/navigation_manager/e2e.py +110 -0
- navigation_manager-0.3.1/navigation_manager/manager.py +195 -0
- navigation_manager-0.3.1/navigation_manager/maps.py +220 -0
- navigation_manager-0.3.1/navigation_manager/server.py +133 -0
- navigation_manager-0.3.1/navigation_manager/stack.py +448 -0
- navigation_manager-0.3.1/navigation_manager/state.py +131 -0
- navigation_manager-0.3.1/navigation_manager/web/index.html +227 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/PKG-INFO +39 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/SOURCES.txt +22 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/dependency_links.txt +1 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/entry_points.txt +2 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/requires.txt +1 -0
- navigation_manager-0.3.1/navigation_manager.egg-info/top_level.txt +1 -0
- navigation_manager-0.3.1/pyproject.toml +25 -0
- navigation_manager-0.3.1/setup.cfg +4 -0
- navigation_manager-0.3.1/tests/test_apps.py +53 -0
- navigation_manager-0.3.1/tests/test_maps.py +237 -0
- navigation_manager-0.3.1/tests/test_stack.py +133 -0
- navigation_manager-0.3.1/tests/test_state.py +83 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: navigation-manager
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Navigation manager for the JUIZ 2D navigation stack: goal -> plan -> follow -> arrive over HTTP, with a browser UI
|
|
5
|
+
License: MIT OR Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/OpenJUIZ/navigation_manager
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: PyYAML>=5.1
|
|
10
|
+
|
|
11
|
+
# navigation-manager
|
|
12
|
+
|
|
13
|
+
Navigation manager for the [JUIZ](https://github.com/OpenJUIZ/juiz) 2D navigation stack
|
|
14
|
+
([mobile_sim](https://github.com/OpenJUIZ/mobile_sim), [localization_amcl](https://github.com/OpenJUIZ/localization_amcl),
|
|
15
|
+
[planner_dijkstra](https://github.com/OpenJUIZ/planner_dijkstra), [planner_dwa](https://github.com/OpenJUIZ/planner_dwa) and
|
|
16
|
+
navigation_hub from [navigation_manager](https://github.com/OpenJUIZ/navigation_manager)).
|
|
17
|
+
It runs the goal → plan → follow → arrive state machine over HTTP and serves a browser UI on http://localhost:8005/.
|
|
18
|
+
|
|
19
|
+
## Start the whole stack
|
|
20
|
+
|
|
21
|
+
Download the five `.juizapp` packages from the Releases of each repository, then:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install juiz-cli==0.2.4 navigation-manager
|
|
25
|
+
navigation-manager stack mobile_sim-0.3.3.juizapp localization_amcl-0.1.0.juizapp \
|
|
26
|
+
planner_dijkstra-0.1.0.juizapp planner_dwa-0.1.0.juizapp navigation_hub-0.3.1.juizapp [--map map.yaml]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`navigation-manager stack` starts the apps in the order the stack needs (navigation_hub as the root of one tree),
|
|
30
|
+
then the manager itself. `--map` distributes a map YAML (ROS map_server format, e.g. one made with SLAM) instead of the
|
|
31
|
+
mobile_sim map. Ctrl-C stops everything. Works on Linux, macOS and Windows.
|
|
32
|
+
|
|
33
|
+
To run only the manager against apps that are already running: `navigation-manager [--map map.yaml]`.
|
|
34
|
+
|
|
35
|
+
See the [repository](https://github.com/OpenJUIZ/navigation_manager) for details (in Japanese).
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT OR Apache-2.0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# navigation-manager
|
|
2
|
+
|
|
3
|
+
Navigation manager for the [JUIZ](https://github.com/OpenJUIZ/juiz) 2D navigation stack
|
|
4
|
+
([mobile_sim](https://github.com/OpenJUIZ/mobile_sim), [localization_amcl](https://github.com/OpenJUIZ/localization_amcl),
|
|
5
|
+
[planner_dijkstra](https://github.com/OpenJUIZ/planner_dijkstra), [planner_dwa](https://github.com/OpenJUIZ/planner_dwa) and
|
|
6
|
+
navigation_hub from [navigation_manager](https://github.com/OpenJUIZ/navigation_manager)).
|
|
7
|
+
It runs the goal → plan → follow → arrive state machine over HTTP and serves a browser UI on http://localhost:8005/.
|
|
8
|
+
|
|
9
|
+
## Start the whole stack
|
|
10
|
+
|
|
11
|
+
Download the five `.juizapp` packages from the Releases of each repository, then:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install juiz-cli==0.2.4 navigation-manager
|
|
15
|
+
navigation-manager stack mobile_sim-0.3.3.juizapp localization_amcl-0.1.0.juizapp \
|
|
16
|
+
planner_dijkstra-0.1.0.juizapp planner_dwa-0.1.0.juizapp navigation_hub-0.3.1.juizapp [--map map.yaml]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`navigation-manager stack` starts the apps in the order the stack needs (navigation_hub as the root of one tree),
|
|
20
|
+
then the manager itself. `--map` distributes a map YAML (ROS map_server format, e.g. one made with SLAM) instead of the
|
|
21
|
+
mobile_sim map. Ctrl-C stops everything. Works on Linux, macOS and Windows.
|
|
22
|
+
|
|
23
|
+
To run only the manager against apps that are already running: `navigation-manager [--map map.yaml]`.
|
|
24
|
+
|
|
25
|
+
See the [repository](https://github.com/OpenJUIZ/navigation_manager) for details (in Japanese).
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT OR Apache-2.0
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""navigation_manager を起動する。
|
|
2
|
+
|
|
3
|
+
navigation-manager [--port 8005] [--map map.yaml] [--sim URL] [--amcl URL] [--planner URL] [--dwa URL] [--hub URL]
|
|
4
|
+
navigation-manager stack <.juizapp>... [--map map.yaml] # 一式をまとめて起動する(navigation-manager stack --help)
|
|
5
|
+
|
|
6
|
+
先にナビゲーション一式(mobile_sim・localization_amcl・planner_dijkstra・planner_dwa・navigation_hub)を
|
|
7
|
+
起動しておく(navigation-manager stack はそれも含めて全部起動する)。ブラウザで http://localhost:8005/ を開く。
|
|
8
|
+
起動時に mobile_sim の地図を planner_dijkstra・localization_amcl に配る。--map で地図の YAML(SLAM で作ったものなど)を渡すと、
|
|
9
|
+
mobile_sim の地図の代わりにそれを配り、画面にもその地図を描く。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from .apps import Apps, Urls, ready
|
|
19
|
+
from .manager import Manager
|
|
20
|
+
from .maps import MapError
|
|
21
|
+
from .server import serve
|
|
22
|
+
from .state import Params
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv=None) -> None:
|
|
26
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
27
|
+
if argv and argv[0] == "stack":
|
|
28
|
+
from .stack import main as stack_main
|
|
29
|
+
stack_main(argv[1:])
|
|
30
|
+
return
|
|
31
|
+
d = Urls()
|
|
32
|
+
p = Params()
|
|
33
|
+
ap = argparse.ArgumentParser(prog="navigation-manager", description=__doc__,
|
|
34
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
35
|
+
ap.add_argument("--host", default="0.0.0.0")
|
|
36
|
+
ap.add_argument("--port", type=int, default=8005)
|
|
37
|
+
ap.add_argument("--sim", default=d.sim, help="mobile_sim の URL")
|
|
38
|
+
ap.add_argument("--amcl", default=d.amcl, help="localization_amcl の URL")
|
|
39
|
+
ap.add_argument("--planner", default=d.planner, help="planner_dijkstra の URL")
|
|
40
|
+
ap.add_argument("--dwa", default=d.dwa, help="planner_dwa の URL")
|
|
41
|
+
ap.add_argument("--hub", default=None,
|
|
42
|
+
help="navigation_hub の URL。渡すとそれも応答するまで待つ(Docker で一式と同時に起動するとき)")
|
|
43
|
+
ap.add_argument("--rate", type=float, default=5.0, help="周期処理の周波数 [Hz]")
|
|
44
|
+
ap.add_argument("--xy-goal-tolerance", type=float, default=p.xy_goal_tolerance)
|
|
45
|
+
ap.add_argument("--yaw-goal-tolerance", type=float, default=p.yaw_goal_tolerance)
|
|
46
|
+
ap.add_argument("--max-planning-retries", type=int, default=p.max_planning_retries)
|
|
47
|
+
ap.add_argument("--initial-pose-from-sim", action="store_true",
|
|
48
|
+
help="起動時に mobile_sim の真の姿勢を localization_amcl の初期位置にする")
|
|
49
|
+
ap.add_argument("--map", metavar="YAML",
|
|
50
|
+
help="起動時に配る地図の YAML(ROS の map_server の形。image は YAML から見た相対パス)。省くと mobile_sim の地図")
|
|
51
|
+
ap.add_argument("--no-distribute-map", action="store_true",
|
|
52
|
+
help="起動時に mobile_sim の地図を planner_dijkstra・localization_amcl に渡さない(各アプリの地図のまま)")
|
|
53
|
+
ap.add_argument("--wait", type=float, default=30.0, help="各アプリが応答するまで待つ秒数")
|
|
54
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
55
|
+
a = ap.parse_args(argv)
|
|
56
|
+
if a.map and a.no_distribute_map:
|
|
57
|
+
ap.error("--map and --no-distribute-map cannot be used together")
|
|
58
|
+
logging.basicConfig(level=logging.DEBUG if a.verbose else logging.INFO,
|
|
59
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
60
|
+
log = logging.getLogger("navigation_manager")
|
|
61
|
+
|
|
62
|
+
urls = Urls(a.sim, a.amcl, a.planner, a.dwa)
|
|
63
|
+
end = time.time() + a.wait
|
|
64
|
+
waits = [(name, getattr(urls, name)) for name in ("sim", "amcl", "planner", "dwa")]
|
|
65
|
+
if a.hub:
|
|
66
|
+
waits.append(("hub", a.hub))
|
|
67
|
+
for name, url in waits:
|
|
68
|
+
while not ready(url):
|
|
69
|
+
if time.time() > end:
|
|
70
|
+
raise SystemExit(f"{name} is not running at {url}")
|
|
71
|
+
time.sleep(0.5)
|
|
72
|
+
manager = Manager(Apps(urls),
|
|
73
|
+
Params(a.xy_goal_tolerance, a.yaw_goal_tolerance, a.max_planning_retries), a.rate)
|
|
74
|
+
if a.map:
|
|
75
|
+
try:
|
|
76
|
+
manager.load_map(a.map)
|
|
77
|
+
except MapError as e:
|
|
78
|
+
raise SystemExit(f"--map: {e}")
|
|
79
|
+
elif not a.no_distribute_map:
|
|
80
|
+
manager.distribute_map()
|
|
81
|
+
if a.initial_pose_from_sim:
|
|
82
|
+
p0 = manager.set_initial_pose()
|
|
83
|
+
log.info("initial pose from mobile_sim: %s", p0)
|
|
84
|
+
manager.start()
|
|
85
|
+
server = serve(manager, a.host, a.port)
|
|
86
|
+
log.info("navigation_manager on http://localhost:%d/", a.port)
|
|
87
|
+
try:
|
|
88
|
+
server.serve_forever()
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
pass
|
|
91
|
+
finally:
|
|
92
|
+
server.server_close()
|
|
93
|
+
manager.shutdown()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""ナビゲーション一式の各アプリを juiz の HTTP API で呼ぶ。
|
|
2
|
+
|
|
3
|
+
| アプリ | 既定の URL | 使う ContainerProcess |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| mobile_sim | http://localhost:8000 | sim0: get_pose0・get_map0・get_map_info0・get_state0 |
|
|
6
|
+
| localization_amcl | http://localhost:8002 | amcl0: get_pose0・set_initial_pose0・set_map0・get_state0(粒子) |
|
|
7
|
+
| planner_dijkstra | http://localhost:8003 | planner0: set_goal0(計画して経路を返す)・set_map0 |
|
|
8
|
+
| planner_dwa | http://localhost:8004 | dwa0: set_path0・cancel0・get_status0・get_state0(局所計画・障害物) |
|
|
9
|
+
|
|
10
|
+
ContainerProcess の ID は `<URL>/container_process/<name>::<type_name>:<container>`。
|
|
11
|
+
HTTP の call は戻り値を返すだけで、トピックには出さない(必要な受け渡しは manager が自分で呼ぶ)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
from .state import Pose
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AppError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Urls:
|
|
32
|
+
sim: str = "http://localhost:8000"
|
|
33
|
+
amcl: str = "http://localhost:8002"
|
|
34
|
+
planner: str = "http://localhost:8003"
|
|
35
|
+
dwa: str = "http://localhost:8004"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _request(url: str, body: Any = None, method: str = "GET", timeout: float = 5.0) -> Tuple[str, bytes]:
|
|
39
|
+
data = None if body is None else json.dumps(body).encode()
|
|
40
|
+
req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
43
|
+
return r.headers.get("Content-Type", ""), r.read()
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
raise AppError(f"{url}: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") from e
|
|
46
|
+
except OSError as e:
|
|
47
|
+
raise AppError(f"{url}: {e}") from e
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def call(base: str, container: str, name: str, type_name: str, args: Optional[dict] = None, raw: bool = False):
|
|
51
|
+
ident = f"{base}/container_process/{name}::{type_name}:{container}"
|
|
52
|
+
url = f"{base}/api/container_process/call?identifier=" + urllib.parse.quote(ident, safe="")
|
|
53
|
+
ctype, body = _request(url, args or {}, "PATCH")
|
|
54
|
+
if raw:
|
|
55
|
+
return ctype, body
|
|
56
|
+
return json.loads(body) if body else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def call_multipart(base: str, container: str, name: str, type_name: str, parts: dict):
|
|
60
|
+
"""画像の引数を持つ Process を multipart の PUT で呼ぶ。bytes の値は image/png、ほかは JSON の部分にする"""
|
|
61
|
+
ident = f"{base}/container_process/{name}::{type_name}:{container}"
|
|
62
|
+
url = f"{base}/api/container_process/call?identifier=" + urllib.parse.quote(ident, safe="")
|
|
63
|
+
boundary = uuid.uuid4().hex
|
|
64
|
+
chunks = []
|
|
65
|
+
for k, v in parts.items():
|
|
66
|
+
data, ctype = (v, "image/png") if isinstance(v, bytes) else (json.dumps(v).encode(), "application/json")
|
|
67
|
+
chunks.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"; filename="{k}"\r\n'
|
|
68
|
+
f"Content-Type: {ctype}\r\n\r\n".encode() + data + b"\r\n")
|
|
69
|
+
body = b"".join(chunks) + f"--{boundary}--\r\n".encode()
|
|
70
|
+
req = urllib.request.Request(url, data=body, method="PUT",
|
|
71
|
+
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, timeout=10.0) as r:
|
|
74
|
+
out = r.read()
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
raise AppError(f"{url}: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") from e
|
|
77
|
+
except OSError as e:
|
|
78
|
+
raise AppError(f"{url}: {e}") from e
|
|
79
|
+
return json.loads(out) if out else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def map_parts(png: bytes, info: dict) -> dict:
|
|
83
|
+
"""mobile_sim の get_map・get_map_info を、planner_set_map・amcl_set_map の引数にする(両方同じ形)"""
|
|
84
|
+
return {
|
|
85
|
+
"image": png,
|
|
86
|
+
"resolution": float(info["resolution"]),
|
|
87
|
+
"origin": list(info["origin"]),
|
|
88
|
+
"negate": bool(info.get("negate", False)),
|
|
89
|
+
"occupied_thresh": float(info.get("occupied_thresh", 0.65)),
|
|
90
|
+
"free_thresh": float(info.get("free_thresh", 0.196)),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ready(base: str) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
_request(f"{base}/api/container/list", timeout=1.0)
|
|
97
|
+
return True
|
|
98
|
+
except AppError:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pose_from_value(v: Any) -> Optional[Pose]:
|
|
103
|
+
"""Pose2DStamped(`{header, pose: {position: {x, y}, theta}}`)・Pose2D・`{x, y, theta}` を読む"""
|
|
104
|
+
if not isinstance(v, dict):
|
|
105
|
+
return None
|
|
106
|
+
p = v.get("pose", v)
|
|
107
|
+
if isinstance(p, dict) and "position" in p:
|
|
108
|
+
return Pose(float(p["position"]["x"]), float(p["position"]["y"]), float(p.get("theta", 0.0)))
|
|
109
|
+
if isinstance(p, dict) and "x" in p and "y" in p:
|
|
110
|
+
return Pose(float(p["x"]), float(p["y"]), float(p.get("theta", 0.0)))
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def pose_stamped(p: Pose, frame_id: str = "map") -> dict:
|
|
115
|
+
t = time.time()
|
|
116
|
+
return {
|
|
117
|
+
"header": {"stamp": {"sec": int(t), "nanosec": int((t - int(t)) * 1e9)}, "frame_id": frame_id},
|
|
118
|
+
"pose": {"position": {"x": p.x, "y": p.y}, "theta": p.theta},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Apps:
|
|
123
|
+
def __init__(self, urls: Urls):
|
|
124
|
+
self.urls = urls
|
|
125
|
+
|
|
126
|
+
# mobile_sim
|
|
127
|
+
def true_pose(self) -> Optional[Pose]:
|
|
128
|
+
return pose_from_value(call(self.urls.sim, "sim0", "get_pose0", "mobile_sim_get_pose", {"index": 0}))
|
|
129
|
+
|
|
130
|
+
def map_png(self) -> Tuple[str, bytes]:
|
|
131
|
+
return call(self.urls.sim, "sim0", "get_map0", "mobile_sim_get_map", raw=True)
|
|
132
|
+
|
|
133
|
+
def map_info(self) -> dict:
|
|
134
|
+
return call(self.urls.sim, "sim0", "get_map_info0", "mobile_sim_get_map_info")
|
|
135
|
+
|
|
136
|
+
def sim_state(self) -> dict:
|
|
137
|
+
return call(self.urls.sim, "sim0", "get_state0", "mobile_sim_get_state")
|
|
138
|
+
|
|
139
|
+
# localization_amcl
|
|
140
|
+
def estimated_pose(self) -> Optional[Pose]:
|
|
141
|
+
return pose_from_value(call(self.urls.amcl, "amcl0", "get_pose0", "amcl_get_pose"))
|
|
142
|
+
|
|
143
|
+
def set_initial_pose(self, p: Pose) -> None:
|
|
144
|
+
call(self.urls.amcl, "amcl0", "set_initial_pose0", "amcl_set_initial_pose",
|
|
145
|
+
{"pose": pose_stamped(p), "cov_xx": -1.0, "cov_yy": -1.0, "cov_aa": -1.0})
|
|
146
|
+
|
|
147
|
+
def set_amcl_map(self, parts: dict) -> None:
|
|
148
|
+
call_multipart(self.urls.amcl, "amcl0", "set_map0", "amcl_set_map", parts)
|
|
149
|
+
|
|
150
|
+
def amcl_state(self) -> dict:
|
|
151
|
+
return call(self.urls.amcl, "amcl0", "get_state0", "amcl_get_state")
|
|
152
|
+
|
|
153
|
+
# planner_dijkstra
|
|
154
|
+
def set_planner_map(self, parts: dict) -> None:
|
|
155
|
+
call_multipart(self.urls.planner, "planner0", "set_map0", "planner_set_map", parts)
|
|
156
|
+
|
|
157
|
+
def plan(self, goal: Pose) -> dict:
|
|
158
|
+
"""ゴールを渡して計画させる。始点はトピック nav/amcl_pose で planner に届いている推定姿勢"""
|
|
159
|
+
return call(self.urls.planner, "planner0", "set_goal0", "planner_set_goal", {"goal": pose_stamped(goal)})
|
|
160
|
+
|
|
161
|
+
# planner_dwa
|
|
162
|
+
def follow(self, path: dict) -> None:
|
|
163
|
+
call(self.urls.dwa, "dwa0", "set_path0", "dwa_set_path", {"path": path})
|
|
164
|
+
|
|
165
|
+
def stop(self) -> None:
|
|
166
|
+
call(self.urls.dwa, "dwa0", "cancel0", "dwa_cancel")
|
|
167
|
+
|
|
168
|
+
def local_status(self) -> dict:
|
|
169
|
+
return call(self.urls.dwa, "dwa0", "get_status0", "dwa_get_status")
|
|
170
|
+
|
|
171
|
+
def local_state(self) -> dict:
|
|
172
|
+
return call(self.urls.dwa, "dwa0", "get_state0", "dwa_get_state")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""一式が動いているところに、ゴールを出して着くまでと、キャンセルで止まることを確かめる(navigation-manager stack --test、CI の e2e)。
|
|
2
|
+
|
|
3
|
+
navigation_manager がゴールで SUCCEEDED になり、mobile_sim の真の姿勢がゴールの近くにあること、表示(粒子・局所計画・障害物)、
|
|
4
|
+
キャンセルで止まることを見る。失敗したら E2EError を出す。
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
MANAGER = "http://127.0.0.1:8005"
|
|
13
|
+
SIM = "http://127.0.0.1:8000"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class E2EError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fail(message):
|
|
21
|
+
raise E2EError(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def api(base, path, query=None, body=None, method="GET", timeout=5):
|
|
25
|
+
url = f"{base}/api/{path}"
|
|
26
|
+
if query:
|
|
27
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
28
|
+
data = None if body is None else json.dumps(body).encode()
|
|
29
|
+
req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
|
|
30
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
31
|
+
return json.loads(r.read())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def call(base, name, ptype, container, args=None):
|
|
35
|
+
ident = f"{base}/container_process/{name}::{ptype}:{container}"
|
|
36
|
+
return api(base, "container_process/call", {"identifier": ident}, args or {}, "PATCH")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def status():
|
|
40
|
+
return api(MANAGER, "status")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_test(goal, timeout, map_source="mobile_sim"):
|
|
44
|
+
"""ゴールを出して、navigation_manager が SUCCEEDED になり、mobile_sim の真の姿勢がゴールの近くにあることを確かめる"""
|
|
45
|
+
# 自己位置推定が落ち着くまで待つ(amcl_pose が navigation_manager に届くこと)
|
|
46
|
+
end = time.time() + 30
|
|
47
|
+
while status().get("pose") is None:
|
|
48
|
+
if time.time() > end:
|
|
49
|
+
fail("amcl_pose did not reach navigation_manager")
|
|
50
|
+
time.sleep(0.5)
|
|
51
|
+
print("amcl_pose reached navigation_manager:", status()["pose"], flush=True)
|
|
52
|
+
if status().get("map_source") != map_source:
|
|
53
|
+
fail(f"the {map_source} map was not distributed: {status()}")
|
|
54
|
+
info = api(MANAGER, "map_info")
|
|
55
|
+
print(f"map: {map_source} ({info['width']}x{info['height']}, {info['resolution']} m/px, origin {info['origin']})",
|
|
56
|
+
flush=True)
|
|
57
|
+
v = api(MANAGER, "view")
|
|
58
|
+
print(f"view: {len(v['particles'])} particles", flush=True)
|
|
59
|
+
if not v["particles"]:
|
|
60
|
+
fail("the view has no particles")
|
|
61
|
+
|
|
62
|
+
x, y, theta = goal
|
|
63
|
+
api(MANAGER, "goal", body={"x": x, "y": y, "theta": theta}, method="POST")
|
|
64
|
+
print(f"goal ({x}, {y}, {theta})", flush=True)
|
|
65
|
+
end = time.time() + timeout
|
|
66
|
+
last = None
|
|
67
|
+
seen = set()
|
|
68
|
+
while time.time() < end:
|
|
69
|
+
s = status()
|
|
70
|
+
seen.add(s["state"])
|
|
71
|
+
if (s["state"], s["message"]) != last:
|
|
72
|
+
print(f" {s['state']}: {s['message']} (distance {s['goal_distance']})", flush=True)
|
|
73
|
+
last = (s["state"], s["message"])
|
|
74
|
+
if s["state"] in ("succeeded", "failed"):
|
|
75
|
+
break
|
|
76
|
+
time.sleep(0.5)
|
|
77
|
+
if s["state"] != "succeeded":
|
|
78
|
+
fail(f"navigation did not succeed: {s}")
|
|
79
|
+
if "following" not in seen:
|
|
80
|
+
fail(f"never followed a path: {seen}")
|
|
81
|
+
truth = call(SIM, "get_pose0", "mobile_sim_get_pose", "sim0", {"index": 0})["pose"]
|
|
82
|
+
d = math.hypot(truth["position"]["x"] - x, truth["position"]["y"] - y)
|
|
83
|
+
print(f"true pose {truth} ({d:.2f} m from the goal)", flush=True)
|
|
84
|
+
if d > 0.5:
|
|
85
|
+
fail(f"the robot is {d:.2f} m from the goal")
|
|
86
|
+
|
|
87
|
+
# 2 つ目のゴールを出して、追っている途中でキャンセルすると止まる
|
|
88
|
+
api(MANAGER, "goal", body={"x": -2.0, "y": -2.0, "theta": 0.0}, method="POST")
|
|
89
|
+
end = time.time() + 20
|
|
90
|
+
while status()["state"] != "following":
|
|
91
|
+
if time.time() > end:
|
|
92
|
+
fail(f"second goal was not followed: {status()}")
|
|
93
|
+
time.sleep(0.2)
|
|
94
|
+
time.sleep(2.0)
|
|
95
|
+
v = api(MANAGER, "view")
|
|
96
|
+
print(f"view while following: {len(v['local_plan'])} local plan points, {len(v['obstacles'])} obstacles", flush=True)
|
|
97
|
+
if not v["local_plan"] or not v["obstacles"]:
|
|
98
|
+
fail("the view has no local plan or obstacles while following")
|
|
99
|
+
s = api(MANAGER, "cancel", body={}, method="POST")
|
|
100
|
+
if s["state"] != "canceled":
|
|
101
|
+
fail(f"cancel did not cancel: {s}")
|
|
102
|
+
time.sleep(1.0) # 減速を待つ
|
|
103
|
+
a = call(SIM, "get_pose0", "mobile_sim_get_pose", "sim0", {"index": 0})["pose"]["position"]
|
|
104
|
+
time.sleep(1.0)
|
|
105
|
+
b = call(SIM, "get_pose0", "mobile_sim_get_pose", "sim0", {"index": 0})["pose"]["position"]
|
|
106
|
+
moved = math.hypot(a["x"] - b["x"], a["y"] - b["y"])
|
|
107
|
+
print(f"canceled; moved {moved:.3f} m in 1 s after stopping", flush=True)
|
|
108
|
+
if moved > 0.05:
|
|
109
|
+
fail(f"the robot kept moving after cancel ({moved:.3f} m in 1 s)")
|
|
110
|
+
print("end-to-end test passed", flush=True)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""状態機械を動かし、その Action に従って各アプリを HTTP で呼ぶ。
|
|
2
|
+
|
|
3
|
+
- 周期処理(既定 5 Hz): localization_amcl の推定姿勢と planner_dwa の状態を読み、状態機械に渡す
|
|
4
|
+
- ゴール: planner_dijkstra の set_goal を呼んで経路を受け、planner_dwa の set_path に渡す(引き直しも同じ)
|
|
5
|
+
- キャンセル・到着・失敗: planner_dwa の cancel を呼んで止める
|
|
6
|
+
- 初期位置: mobile_sim の真の姿勢(または指定の姿勢)を localization_amcl の set_initial_pose に渡す
|
|
7
|
+
- 地図の配布: mobile_sim の地図(get_map・get_map_info)、または地図の YAML(SLAM で作ったものなど)を
|
|
8
|
+
planner_dijkstra と localization_amcl の set_map に渡す。配った地図を覚えておき、画面にはその地図を描く
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from .apps import AppError, Apps, map_parts
|
|
18
|
+
from .maps import MapData, load_map_file, load_map_upload
|
|
19
|
+
from .state import PLAN, STOP, Navigator, Params, Pose
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("navigation_manager")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Manager:
|
|
25
|
+
def __init__(self, apps: Apps, params: Optional[Params] = None, rate: float = 5.0):
|
|
26
|
+
self.apps = apps
|
|
27
|
+
self.nav = Navigator(params or Params())
|
|
28
|
+
self.rate = rate
|
|
29
|
+
self.lock = threading.RLock()
|
|
30
|
+
self.path: Optional[dict] = None
|
|
31
|
+
self.local: Optional[dict] = None
|
|
32
|
+
self.last_error: Optional[str] = None
|
|
33
|
+
self.map: Optional[MapData] = None # 配った地図。None なら各アプリの地図のまま(画面は mobile_sim の地図)
|
|
34
|
+
self.map_version = 0 # 地図を配るたびに増やす(画面が描き直すため)
|
|
35
|
+
self._stop = threading.Event()
|
|
36
|
+
self._thread: Optional[threading.Thread] = None
|
|
37
|
+
|
|
38
|
+
# 外からのイベント(UI・API)
|
|
39
|
+
def set_goal(self, goal: Pose) -> dict:
|
|
40
|
+
with self.lock:
|
|
41
|
+
self._run(self.nav.set_goal(goal, time.time()))
|
|
42
|
+
return self.status()
|
|
43
|
+
|
|
44
|
+
def cancel(self) -> dict:
|
|
45
|
+
with self.lock:
|
|
46
|
+
self._run(self.nav.cancel(time.time()))
|
|
47
|
+
return self.status()
|
|
48
|
+
|
|
49
|
+
def set_initial_pose(self, pose: Optional[Pose] = None) -> Pose:
|
|
50
|
+
"""pose を省くと mobile_sim の真の姿勢を使う"""
|
|
51
|
+
p = pose or self.apps.true_pose()
|
|
52
|
+
if p is None:
|
|
53
|
+
raise AppError("mobile_sim did not return a pose")
|
|
54
|
+
self.apps.set_initial_pose(p)
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
def distribute_map(self) -> dict:
|
|
58
|
+
"""mobile_sim の地図を planner_dijkstra と localization_amcl に渡す。追っている途中なら止める"""
|
|
59
|
+
png_type, png = self.apps.map_png()
|
|
60
|
+
info = self.apps.map_info()
|
|
61
|
+
return self._distribute(MapData(png, dict(info), "mobile_sim"))
|
|
62
|
+
|
|
63
|
+
def load_map(self, path) -> dict:
|
|
64
|
+
"""地図の YAML(とそこに書かれた画像)を読んで、planner_dijkstra と localization_amcl に渡す"""
|
|
65
|
+
return self._distribute(load_map_file(path))
|
|
66
|
+
|
|
67
|
+
def load_map_upload(self, yaml_text: str, images: dict, name: Optional[str] = None) -> dict:
|
|
68
|
+
"""画面から送られた地図の YAML と画像を読んで渡す"""
|
|
69
|
+
return self._distribute(load_map_upload(yaml_text, images, name))
|
|
70
|
+
|
|
71
|
+
def _distribute(self, m: MapData) -> dict:
|
|
72
|
+
parts = map_parts(m.png, m.info)
|
|
73
|
+
with self.lock:
|
|
74
|
+
if self.nav.state in ("planning", "following"):
|
|
75
|
+
self._run(self.nav.cancel(time.time()))
|
|
76
|
+
self.apps.set_planner_map(parts)
|
|
77
|
+
self.apps.set_amcl_map(parts)
|
|
78
|
+
self.map = m
|
|
79
|
+
self.map_version += 1
|
|
80
|
+
self.path = None
|
|
81
|
+
log.info("distributed the %s map (%sx%s, %s m/px) to planner_dijkstra and localization_amcl",
|
|
82
|
+
m.source, m.info.get("width"), m.info.get("height"), m.info.get("resolution"))
|
|
83
|
+
return {"source": m.source, "info": m.info}
|
|
84
|
+
|
|
85
|
+
# 画面に描く地図: 配った地図、まだ配っていなければ mobile_sim の地図
|
|
86
|
+
def map_png(self):
|
|
87
|
+
m = self.map
|
|
88
|
+
if m is not None:
|
|
89
|
+
return "image/png", m.png
|
|
90
|
+
return self.apps.map_png()
|
|
91
|
+
|
|
92
|
+
def map_info(self) -> dict:
|
|
93
|
+
m = self.map
|
|
94
|
+
if m is not None:
|
|
95
|
+
return dict(m.info, source=m.source)
|
|
96
|
+
return self.apps.map_info()
|
|
97
|
+
|
|
98
|
+
# 周期処理
|
|
99
|
+
def step(self) -> None:
|
|
100
|
+
with self.lock:
|
|
101
|
+
now = time.time()
|
|
102
|
+
try:
|
|
103
|
+
pose = self.apps.estimated_pose()
|
|
104
|
+
if pose is not None:
|
|
105
|
+
self._run(self.nav.on_pose(pose, now))
|
|
106
|
+
self.local = self.apps.local_status()
|
|
107
|
+
self._run(self.nav.on_local_status(str(self.local.get("status", "")), now))
|
|
108
|
+
self.last_error = None
|
|
109
|
+
except AppError as e:
|
|
110
|
+
self.last_error = str(e)
|
|
111
|
+
log.warning("%s", e)
|
|
112
|
+
|
|
113
|
+
def _run(self, actions: List[str]) -> None:
|
|
114
|
+
for a in actions:
|
|
115
|
+
if a == PLAN:
|
|
116
|
+
self._plan()
|
|
117
|
+
elif a == STOP:
|
|
118
|
+
try:
|
|
119
|
+
self.apps.stop()
|
|
120
|
+
except AppError as e:
|
|
121
|
+
self.last_error = str(e)
|
|
122
|
+
log.warning("stop failed: %s", e)
|
|
123
|
+
|
|
124
|
+
def _plan(self) -> None:
|
|
125
|
+
goal = self.nav.goal
|
|
126
|
+
if goal is None:
|
|
127
|
+
return
|
|
128
|
+
try:
|
|
129
|
+
path = self.apps.plan(goal)
|
|
130
|
+
except AppError as e:
|
|
131
|
+
path = {"ok": False, "message": str(e), "poses": []}
|
|
132
|
+
self.path = path
|
|
133
|
+
ok = bool(path.get("ok"))
|
|
134
|
+
if ok:
|
|
135
|
+
try:
|
|
136
|
+
self.apps.follow(path)
|
|
137
|
+
except AppError as e:
|
|
138
|
+
ok = False
|
|
139
|
+
path = dict(path, message=f"planner_dwa refused the path: {e}")
|
|
140
|
+
# 失敗なら状態機械が引き直しか FAILED を決め、また PLAN を返すことがある
|
|
141
|
+
self._run(self.nav.on_plan_result(ok, str(path.get("message", "")), time.time()))
|
|
142
|
+
|
|
143
|
+
def status(self) -> dict:
|
|
144
|
+
with self.lock:
|
|
145
|
+
s = self.nav.status()
|
|
146
|
+
s["local"] = self.local
|
|
147
|
+
s["path_ok"] = None if self.path is None else bool(self.path.get("ok"))
|
|
148
|
+
s["error"] = self.last_error
|
|
149
|
+
s["map_source"] = None if self.map is None else self.map.source
|
|
150
|
+
s["map_version"] = self.map_version
|
|
151
|
+
return s
|
|
152
|
+
|
|
153
|
+
def view(self) -> dict:
|
|
154
|
+
"""画面用: 状態、真の姿勢、推定姿勢、大域経路、局所計画(DWA の最良の軌道)、障害物(スキャン)、粒子"""
|
|
155
|
+
s = self.status()
|
|
156
|
+
try:
|
|
157
|
+
s["true_pose"] = _pose_dict(self.apps.true_pose())
|
|
158
|
+
except AppError:
|
|
159
|
+
s["true_pose"] = None
|
|
160
|
+
try:
|
|
161
|
+
a = self.apps.amcl_state()
|
|
162
|
+
s["particles"] = a.get("particles") or []
|
|
163
|
+
s["particle_count"] = a.get("particle_count")
|
|
164
|
+
except AppError:
|
|
165
|
+
s["particles"], s["particle_count"] = [], None
|
|
166
|
+
try:
|
|
167
|
+
d = self.apps.local_state()
|
|
168
|
+
best = d.get("best") or {}
|
|
169
|
+
s["local_plan"] = best.get("points") or []
|
|
170
|
+
s["obstacles"] = d.get("obstacles") or []
|
|
171
|
+
except AppError:
|
|
172
|
+
s["local_plan"], s["obstacles"] = [], []
|
|
173
|
+
with self.lock:
|
|
174
|
+
s["path"] = self.path
|
|
175
|
+
return s
|
|
176
|
+
|
|
177
|
+
def start(self) -> None:
|
|
178
|
+
def loop():
|
|
179
|
+
period = 1.0 / self.rate
|
|
180
|
+
while not self._stop.is_set():
|
|
181
|
+
t = time.time()
|
|
182
|
+
self.step()
|
|
183
|
+
self._stop.wait(max(0.0, period - (time.time() - t)))
|
|
184
|
+
|
|
185
|
+
self._thread = threading.Thread(target=loop, name="navigation_manager", daemon=True)
|
|
186
|
+
self._thread.start()
|
|
187
|
+
|
|
188
|
+
def shutdown(self) -> None:
|
|
189
|
+
self._stop.set()
|
|
190
|
+
if self._thread:
|
|
191
|
+
self._thread.join(2.0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _pose_dict(p: Optional[Pose]) -> Optional[dict]:
|
|
195
|
+
return None if p is None else {"x": p.x, "y": p.y, "theta": p.theta}
|