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.
@@ -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
@@ -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}