PyRCH 0.1.0__cp310-cp310-win_amd64.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.
- pyrch-0.1.0.dist-info/METADATA +444 -0
- pyrch-0.1.0.dist-info/RECORD +7 -0
- pyrch-0.1.0.dist-info/WHEEL +5 -0
- rch/__init__.py +77 -0
- rch/_api.py +286 -0
- rch/_rch_core.cp310-win_amd64.pyd +0 -0
- rch/_viz.py +243 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: PyRCH
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RCH: Partial-Expansion Anytime Focal Search solver for heterogeneous multi-agent TSP
|
|
5
|
+
Keywords: mtsp,tsp,multi-agent,path-planning,routing,heterogeneous-agents
|
|
6
|
+
Author: MTSP-Solver Team
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Programming Language :: C++
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Project-URL: Homepage, https://github.com/rap-lab-org/dev_peaf
|
|
15
|
+
Project-URL: Repository, https://github.com/rap-lab-org/dev_peaf
|
|
16
|
+
Project-URL: Issues, https://github.com/rap-lab-org/dev_peaf/issues
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Provides-Extra: viz
|
|
21
|
+
Requires-Dist: matplotlib>=3.8; extra == "viz"
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# RCH Solver
|
|
25
|
+
|
|
26
|
+
**RCH** — a solver for the **Heterogeneous Multi-Agent Travelling Salesman Problem (MTSP)**.
|
|
27
|
+
|
|
28
|
+
This package wraps the high-performance C++ solver core via [pybind11](https://github.com/pybind/pybind11) and makes it available as a regular Python library.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Prerequisites
|
|
35
|
+
|
|
36
|
+
- Python >= 3.8
|
|
37
|
+
- A C++17 compiler (`g++ >= 7`, `clang++ >= 5`)
|
|
38
|
+
- CMake >= 3.15
|
|
39
|
+
- pip
|
|
40
|
+
- matplotlib (only needed for visualization helpers)
|
|
41
|
+
|
|
42
|
+
### Install from PyPI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install PyRCH
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If you also want plotting helpers:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install "PyRCH[viz]"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Install from source
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd MTSP-Solver
|
|
58
|
+
|
|
59
|
+
# Install (builds the C++ extension automatically)
|
|
60
|
+
pip install .
|
|
61
|
+
|
|
62
|
+
# Or, for development (editable install):
|
|
63
|
+
pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> **Note**: On Ubuntu 24+ managed Python installs, you may need to add `--break-system-packages` or use a virtual environment.
|
|
67
|
+
|
|
68
|
+
## Release To PyPI
|
|
69
|
+
|
|
70
|
+
If you are publishing manually from a Linux development machine, upload the **sdist** only:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python -m build --sdist
|
|
74
|
+
twine check dist/*
|
|
75
|
+
twine upload dist/pyrch-*.tar.gz
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> **Why not upload the local wheel?** A wheel built locally on Linux is typically tagged like `linux_x86_64` or `linux_aarch64`. PyPI rejects those platform tags. Linux wheels uploaded to PyPI should be built as compliant `manylinux` wheels, which this repository produces through GitHub Actions.
|
|
79
|
+
|
|
80
|
+
To publish wheels:
|
|
81
|
+
|
|
82
|
+
1. Configure PyPI Trusted Publishing for this repository.
|
|
83
|
+
2. Push a version tag such as `v1.0.0`.
|
|
84
|
+
3. Let `.github/workflows/publish-pypi.yml` build and upload the wheels plus sdist.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Quick Start
|
|
89
|
+
|
|
90
|
+
### 1. Solve a JSON problem file
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import rch
|
|
94
|
+
|
|
95
|
+
result = rch.solve("path/to/problem.json", time_limit=10)
|
|
96
|
+
|
|
97
|
+
print(result["status"]) # "success" or "failed"
|
|
98
|
+
print(result["timeout"]) # True if time-limit was reached
|
|
99
|
+
print(result["statistics"]) # {"max_cost": ..., "sum_cost": ..., "solve_time": ..., ...}
|
|
100
|
+
|
|
101
|
+
for route in result["routes"]:
|
|
102
|
+
print(f"Agent {route['agent_id']}: path={route['path']}, cost={route['cost']}")
|
|
103
|
+
|
|
104
|
+
# Anytime improvement history
|
|
105
|
+
for snapshot in result["anytime"]:
|
|
106
|
+
print(f" t={snapshot['time']:.3f}s max_cost={snapshot['max_cost']:.2f}")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 2. Solve from a Python dict
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import rch
|
|
113
|
+
|
|
114
|
+
problem_data = {
|
|
115
|
+
"nodes": [
|
|
116
|
+
{"id": 0, "x": 0.0, "y": 0.0, "type": "depot"},
|
|
117
|
+
{"id": 1, "x": 1.0, "y": 2.0, "type": "target"},
|
|
118
|
+
{"id": 2, "x": 3.0, "y": 1.0, "type": "target"},
|
|
119
|
+
],
|
|
120
|
+
"agents": [
|
|
121
|
+
{"id": 0, "type": "UAV", "start_node": 0, "end_node": 0},
|
|
122
|
+
{"id": 1, "type": "UAV", "start_node": 0, "end_node": 0},
|
|
123
|
+
],
|
|
124
|
+
"costs": {
|
|
125
|
+
"UAV": [
|
|
126
|
+
[0.0, 2.24, 3.16],
|
|
127
|
+
[2.24, 0.0, 2.24],
|
|
128
|
+
[3.16, 2.24, 0.0],
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"options": {
|
|
132
|
+
"return_to_end": True,
|
|
133
|
+
"objective": "min_max"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result = rch.solve(problem_data, time_limit=5)
|
|
138
|
+
print(result)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 3. Planner API (recommended)
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import rch
|
|
145
|
+
|
|
146
|
+
planner = rch.Planner()
|
|
147
|
+
|
|
148
|
+
# Add nodes
|
|
149
|
+
planner.add_depot(0, x=0.0, y=0.0)
|
|
150
|
+
planner.add_target(1, x=1.0, y=2.0)
|
|
151
|
+
planner.add_target(2, x=3.0, y=1.0)
|
|
152
|
+
|
|
153
|
+
# Add agents
|
|
154
|
+
planner.add_agent(agent_id=0, agent_type="UAV", start_node=0, end_node=0, time_limit=6.0)
|
|
155
|
+
planner.add_agent(agent_id=1, agent_type="UGV", start_node=0, end_node=0, time_limit=9.0)
|
|
156
|
+
|
|
157
|
+
# Set cost matrices (one per agent type)
|
|
158
|
+
planner.set_cost_matrix("UAV", [
|
|
159
|
+
[0.0, 2.24, 3.16],
|
|
160
|
+
[2.24, 0.0, 2.24],
|
|
161
|
+
[3.16, 2.24, 0.0],
|
|
162
|
+
])
|
|
163
|
+
planner.set_cost_matrix("UGV", [
|
|
164
|
+
[0.0, 1.80, 4.20],
|
|
165
|
+
[1.80, 0.0, 2.90],
|
|
166
|
+
[4.20, 2.90, 0.0],
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
# Add constraints (optional)
|
|
170
|
+
planner.add_assignment(1, ["UAV"])
|
|
171
|
+
planner.add_assignment(2, ["UGV"])
|
|
172
|
+
planner.add_time_window(1, start=0.0, end=3.0)
|
|
173
|
+
planner.add_time_window(2, start=0.0, end=6.0)
|
|
174
|
+
|
|
175
|
+
# Set solver options
|
|
176
|
+
planner.set_options(return_to_end=True, objective="min_max", time_limit=5)
|
|
177
|
+
|
|
178
|
+
# Visualize the problem map
|
|
179
|
+
planner.show_map()
|
|
180
|
+
|
|
181
|
+
# Solve
|
|
182
|
+
result = planner.solve()
|
|
183
|
+
print(result["status"])
|
|
184
|
+
for route in result["routes"]:
|
|
185
|
+
print(f" Agent {route['agent_id']}: path={route['path']}, cost={route['cost']:.3f}")
|
|
186
|
+
|
|
187
|
+
# Visualize the result
|
|
188
|
+
planner.show_result(result)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 4. Visualization
|
|
192
|
+
|
|
193
|
+
#### `show_map` — view the problem before solving
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# Via Planner method:
|
|
197
|
+
planner.show_map()
|
|
198
|
+
|
|
199
|
+
# Or via module-level function:
|
|
200
|
+
rch.show_map(planner)
|
|
201
|
+
|
|
202
|
+
# Draw on an existing axes (e.g. for subplots):
|
|
203
|
+
import matplotlib.pyplot as plt
|
|
204
|
+
fig, ax = plt.subplots()
|
|
205
|
+
rch.show_map(planner, ax=ax, show=False)
|
|
206
|
+
plt.savefig("map.png")
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Depots are drawn as black stars (★), targets as grey dots, and each agent's start position as a coloured triangle.
|
|
210
|
+
|
|
211
|
+
#### `show_result` — view routes after solving
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
result = planner.solve()
|
|
215
|
+
|
|
216
|
+
# Via Planner method:
|
|
217
|
+
planner.show_result(result)
|
|
218
|
+
|
|
219
|
+
# Or via module-level function:
|
|
220
|
+
rch.show_result(planner, result)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Each agent's route is drawn with a distinct colour and directional arrows.
|
|
224
|
+
|
|
225
|
+
**`return_to_end=False` handling**: when the solver option `return_to_end` is `False`, the solver internally appends the depot as the last node in each route. `show_result` automatically detects this and removes the trailing depot from the visualization — the route will end at the last target visited, without drawing an edge back to the depot.
|
|
226
|
+
|
|
227
|
+
### 5. Low-level programmatic API
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from rch import Problem, Solver, Options, ObjectiveType, NodeType
|
|
231
|
+
from rch import Node, Agent, AssignmentConstraint, TimeWindowConstraint
|
|
232
|
+
|
|
233
|
+
# Build a problem instance in code
|
|
234
|
+
problem = Problem()
|
|
235
|
+
|
|
236
|
+
# Add nodes
|
|
237
|
+
for nid, (x, y), ntype in [(0, (0, 0), NodeType.DEPOT),
|
|
238
|
+
(1, (1, 2), NodeType.TARGET),
|
|
239
|
+
(2, (3, 1), NodeType.TARGET)]:
|
|
240
|
+
n = Node()
|
|
241
|
+
n.id = nid
|
|
242
|
+
n.position.x = x
|
|
243
|
+
n.position.y = y
|
|
244
|
+
n.type = ntype
|
|
245
|
+
problem.add_node(n)
|
|
246
|
+
|
|
247
|
+
# Add agents
|
|
248
|
+
a = Agent()
|
|
249
|
+
a.id = 0
|
|
250
|
+
a.type = "UAV"
|
|
251
|
+
a.start_node = 0
|
|
252
|
+
a.end_node = 0
|
|
253
|
+
problem.add_agent(a, 0)
|
|
254
|
+
|
|
255
|
+
a2 = Agent()
|
|
256
|
+
a2.id = 1
|
|
257
|
+
a2.type = "UAV"
|
|
258
|
+
a2.start_node = 0
|
|
259
|
+
a2.end_node = 0
|
|
260
|
+
problem.add_agent(a2, 1)
|
|
261
|
+
|
|
262
|
+
# Set cost matrix (one per agent type)
|
|
263
|
+
import math
|
|
264
|
+
nodes_xy = [(0,0), (1,2), (3,1)]
|
|
265
|
+
n = len(nodes_xy)
|
|
266
|
+
cost = [[0.0]*n for _ in range(n)]
|
|
267
|
+
for i in range(n):
|
|
268
|
+
for j in range(n):
|
|
269
|
+
dx = nodes_xy[i][0] - nodes_xy[j][0]
|
|
270
|
+
dy = nodes_xy[i][1] - nodes_xy[j][1]
|
|
271
|
+
cost[i][j] = math.sqrt(dx*dx + dy*dy)
|
|
272
|
+
problem.set_cost_matrix("UAV", cost)
|
|
273
|
+
|
|
274
|
+
# Configure options
|
|
275
|
+
problem.options().objective = ObjectiveType.MinMax
|
|
276
|
+
problem.options().return_to_end = True
|
|
277
|
+
problem.options().time_limit = 5.0
|
|
278
|
+
|
|
279
|
+
# Solve
|
|
280
|
+
opts = problem.options()
|
|
281
|
+
solver = Solver(problem, opts)
|
|
282
|
+
ret = solver.solve()
|
|
283
|
+
result = solver.get_result()
|
|
284
|
+
|
|
285
|
+
# Note: this low-level API follows the original C++ convention:
|
|
286
|
+
# ret == 1 means success, ret == 0 means failure.
|
|
287
|
+
print(f"Return code: {ret}")
|
|
288
|
+
print(f"Paths: {result.paths}")
|
|
289
|
+
print(f"Costs: {result.times}")
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## JSON Input Format
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
{
|
|
298
|
+
"nodes": [
|
|
299
|
+
{"id": 0, "x": 0.0, "y": 0.0, "z": 0.0, "type": "depot"},
|
|
300
|
+
{"id": 1, "x": 1.5, "y": 2.3, "z": 0.0, "type": "target"}
|
|
301
|
+
],
|
|
302
|
+
"agents": [
|
|
303
|
+
{
|
|
304
|
+
"id": 0,
|
|
305
|
+
"type": "TypeA",
|
|
306
|
+
"start_node": 0,
|
|
307
|
+
"end_node": 0,
|
|
308
|
+
"max_length": 100.0,
|
|
309
|
+
"capacity": 10.0
|
|
310
|
+
}
|
|
311
|
+
],
|
|
312
|
+
"costs": {
|
|
313
|
+
"TypeA": [[0.0, 1.5], [1.5, 0.0]]
|
|
314
|
+
},
|
|
315
|
+
"constraints": [
|
|
316
|
+
{
|
|
317
|
+
"kind": "assignment",
|
|
318
|
+
"items": [
|
|
319
|
+
{"node": 1, "types": ["TypeA"]}
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"kind": "timewindow",
|
|
324
|
+
"items": [
|
|
325
|
+
{"node": 1, "start": 0.0, "end": 50.0}
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
],
|
|
329
|
+
"options": {
|
|
330
|
+
"return_to_end": true,
|
|
331
|
+
"objective": "min_max",
|
|
332
|
+
"time_limit": 60
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Fields
|
|
338
|
+
|
|
339
|
+
| Field | Description |
|
|
340
|
+
|---|---|
|
|
341
|
+
| `nodes` | List of nodes. Each has `id`, `x`, `y`, optional `z`, `type` (`"depot"` or `"target"`). |
|
|
342
|
+
| `agents` | List of agents. Each has `id`, `type`, `start_node`, `end_node`, optional `max_length`, `capacity`. |
|
|
343
|
+
| `costs` | Dict mapping agent type name → cost matrix (2D array, row = from node id, col = to node id). |
|
|
344
|
+
| `constraints` | Optional. List of constraint blocks. `kind` = `"assignment"` or `"timewindow"`. |
|
|
345
|
+
| `options` | Optional. `return_to_end` (bool), `objective` (`"min_max"` or `"min_sum"`), `time_limit` (seconds). |
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Result Format
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
{
|
|
353
|
+
"status": "success", # "success" or "failed"
|
|
354
|
+
"timeout": False, # True if solver hit time limit
|
|
355
|
+
"routes": [
|
|
356
|
+
{
|
|
357
|
+
"agent_id": 0,
|
|
358
|
+
"path": [0, 2, 0], # ordered node IDs
|
|
359
|
+
"cost": 6.32 # route cost
|
|
360
|
+
},
|
|
361
|
+
...
|
|
362
|
+
],
|
|
363
|
+
"statistics": {
|
|
364
|
+
"solve_time": 0.123, # wall-clock time (s)
|
|
365
|
+
"max_cost": 6.32, # maximum route cost
|
|
366
|
+
"sum_cost": 10.56, # total cost of all routes
|
|
367
|
+
"n_generated": 1500, # labels generated
|
|
368
|
+
"n_expanded": 800, # labels expanded
|
|
369
|
+
"last_update_time": 0.08 # time of last solution improvement
|
|
370
|
+
},
|
|
371
|
+
"anytime": [
|
|
372
|
+
{"time": 0.01, "max_cost": 9.5, "sum_cost": 15.2},
|
|
373
|
+
{"time": 0.05, "max_cost": 7.1, "sum_cost": 12.0},
|
|
374
|
+
...
|
|
375
|
+
]
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## API Reference
|
|
382
|
+
|
|
383
|
+
### `rch.solve(source, *, time_limit=-1) → dict`
|
|
384
|
+
|
|
385
|
+
Solve an MTSP instance.
|
|
386
|
+
|
|
387
|
+
- `source` — file path (`str` / `Path`), raw JSON string, or Python `dict`.
|
|
388
|
+
- `time_limit` — override the time limit in seconds (default: use the value in JSON).
|
|
389
|
+
|
|
390
|
+
### `rch.Planner` (recommended)
|
|
391
|
+
|
|
392
|
+
High-level builder API. All mutating methods return `self` for chaining.
|
|
393
|
+
|
|
394
|
+
- `add_depot(node_id, *, x, y, z=0)` — add a depot node.
|
|
395
|
+
- `add_target(node_id, *, x, y, z=0, demand=0)` — add a target node.
|
|
396
|
+
- `add_agent(*, agent_id, agent_type, start_node, end_node, order=None, capacity_limit=-1, time_limit=-1)` — add an agent. `time_limit` is the max travel distance/time (≤0 means no limit).
|
|
397
|
+
- `set_cost_matrix(agent_type, matrix)` — set cost matrix for an agent type.
|
|
398
|
+
- `add_assignment(node_id, types)` — assign a node to a list of allowed agent types.
|
|
399
|
+
- `add_time_window(node_id, start, end)` — add a time window constraint for a node.
|
|
400
|
+
- `set_options(*, return_to_end=None, objective=None, time_limit=None)` — set solver options. `objective` accepts `"min_max"` or `"min_sum"`.
|
|
401
|
+
- `solve() → dict` — run the solver and return a result dict.
|
|
402
|
+
- `show_map(**kwargs) → Axes` — visualize the problem map (depots, targets, agent starts).
|
|
403
|
+
- `show_result(result, **kwargs) → Axes` — visualize solved routes on the map.
|
|
404
|
+
|
|
405
|
+
### `rch.show_map(planner, *, ax=None, figsize=(8,6), show=True) → Axes`
|
|
406
|
+
|
|
407
|
+
Plot the problem map: depots (★), targets (●), and agent start positions (▲).
|
|
408
|
+
|
|
409
|
+
### `rch.show_result(planner, result, *, ax=None, figsize=(8,6), show=True) → Axes`
|
|
410
|
+
|
|
411
|
+
Plot solved routes on the map. Each agent's path is drawn with a distinct colour and directional arrows. When `return_to_end=False`, the trailing depot is automatically stripped from the visualization.
|
|
412
|
+
|
|
413
|
+
### `rch.Problem`
|
|
414
|
+
|
|
415
|
+
Low-level programmatic problem builder. Methods:
|
|
416
|
+
|
|
417
|
+
- `add_node(node: Node)` — add a node.
|
|
418
|
+
- `add_agent(agent: Agent, id: int)` — add an agent.
|
|
419
|
+
- `set_cost_matrix(agent_type: str, matrix: List[List[float]])` — set cost matrix.
|
|
420
|
+
- `set_assignment_constraint(c: AssignmentConstraint)` — set assignment constraint.
|
|
421
|
+
- `set_timewindow_constraint(c: TimeWindowConstraint)` — set time window constraint.
|
|
422
|
+
- `options() → Options` — access/modify solver options.
|
|
423
|
+
|
|
424
|
+
### `rch.Solver`
|
|
425
|
+
|
|
426
|
+
Low-level solver. Construct with `Solver(problem, options)`.
|
|
427
|
+
|
|
428
|
+
- `solve() → int` — run the solver (1 = success, 0 = failure).
|
|
429
|
+
- `get_result() → Result` — get the final result.
|
|
430
|
+
- `get_result_process() → List[Tuple[float, Result]]` — get full anytime history.
|
|
431
|
+
|
|
432
|
+
### `rch.ObjectiveType`
|
|
433
|
+
|
|
434
|
+
Enum: `ObjectiveType.MinMax`, `ObjectiveType.MinSum`.
|
|
435
|
+
|
|
436
|
+
### `rch.NodeType`
|
|
437
|
+
|
|
438
|
+
Enum: `NodeType.DEPOT`, `NodeType.TARGET`.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## License
|
|
443
|
+
|
|
444
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
rch/__init__.py,sha256=xcLAs7JXtV9W-0CwPNRyjEP2C0ixAwzHqtX0YQd6ay8,1818
|
|
2
|
+
rch/_api.py,sha256=Wlb_tiXxiAOgBWJ7J6SzsnMYcpt5ufz6Kf2-GogYVH8,9638
|
|
3
|
+
rch/_rch_core.cp310-win_amd64.pyd,sha256=V8-m3wdwYEIvTF5mhxvkr5TEqQXcfnSah_RTewPAmq8,639488
|
|
4
|
+
rch/_viz.py,sha256=Z04gacteFMGniVkXypgjHwN0ubvhCjToY5GmnBB_S1c,7820
|
|
5
|
+
pyrch-0.1.0.dist-info/METADATA,sha256=37CEUgNkIcTyj6Gkc2jWr8eOXDOX3ZDkPpz53EZGcRo,12698
|
|
6
|
+
pyrch-0.1.0.dist-info/WHEEL,sha256=rs4XyvYyY8p6GzRLerHjRm6wNH1QAkDMf7SSChBeqNk,106
|
|
7
|
+
pyrch-0.1.0.dist-info/RECORD,,
|
rch/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RCH Solver — Partial-Expansion Anytime Focal Search for Heterogeneous Multi-Agent TSP.
|
|
3
|
+
|
|
4
|
+
Quick start
|
|
5
|
+
-----------
|
|
6
|
+
>>> import rch
|
|
7
|
+
>>> result = rch.solve("problem.json", time_limit=10)
|
|
8
|
+
>>> print(result["status"]) # "success" or "failed"
|
|
9
|
+
>>> print(result["statistics"]) # {"max_cost": ..., "sum_cost": ..., ...}
|
|
10
|
+
|
|
11
|
+
Programmatic API
|
|
12
|
+
----------------
|
|
13
|
+
>>> from rch import Problem, Solver, ObjectiveType, NodeType
|
|
14
|
+
>>> problem = Problem()
|
|
15
|
+
>>> problem.add_target(0, x=1.0, y=2.0)
|
|
16
|
+
>>> ...
|
|
17
|
+
>>> result = problem.solve(time_limit=10)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from rch._api import (
|
|
21
|
+
solve,
|
|
22
|
+
Planner,
|
|
23
|
+
Problem,
|
|
24
|
+
Solver,
|
|
25
|
+
ObjectiveType,
|
|
26
|
+
NodeType,
|
|
27
|
+
Position,
|
|
28
|
+
Node,
|
|
29
|
+
Agent,
|
|
30
|
+
Options,
|
|
31
|
+
Result,
|
|
32
|
+
AssignmentConstraint,
|
|
33
|
+
TimeWindowConstraint,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def show_map(*args, **kwargs):
|
|
40
|
+
try:
|
|
41
|
+
from rch._viz import show_map as _show_map
|
|
42
|
+
except ModuleNotFoundError as exc:
|
|
43
|
+
raise ImportError(
|
|
44
|
+
"Visualization support requires matplotlib. "
|
|
45
|
+
'Install it with: pip install "PyRCH[viz]"'
|
|
46
|
+
) from exc
|
|
47
|
+
return _show_map(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def show_result(*args, **kwargs):
|
|
51
|
+
try:
|
|
52
|
+
from rch._viz import show_result as _show_result
|
|
53
|
+
except ModuleNotFoundError as exc:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"Visualization support requires matplotlib. "
|
|
56
|
+
'Install it with: pip install "PyRCH[viz]"'
|
|
57
|
+
) from exc
|
|
58
|
+
return _show_result(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"solve",
|
|
62
|
+
"Planner",
|
|
63
|
+
"Problem",
|
|
64
|
+
"Solver",
|
|
65
|
+
"ObjectiveType",
|
|
66
|
+
"NodeType",
|
|
67
|
+
"Position",
|
|
68
|
+
"Node",
|
|
69
|
+
"Agent",
|
|
70
|
+
"Options",
|
|
71
|
+
"Result",
|
|
72
|
+
"AssignmentConstraint",
|
|
73
|
+
"TimeWindowConstraint",
|
|
74
|
+
"show_map",
|
|
75
|
+
"show_result",
|
|
76
|
+
"__version__",
|
|
77
|
+
]
|
rch/_api.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level Pythonic API for the RCH MTSP Solver.
|
|
3
|
+
|
|
4
|
+
This module wraps the C++ ``_rch_core`` extension and exposes a
|
|
5
|
+
convenient interface for solving heterogeneous multi-agent TSP instances.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as _json
|
|
11
|
+
import pathlib
|
|
12
|
+
import time as _time
|
|
13
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
# Re-export low-level C++ types so users can do ``from rch import Problem``
|
|
16
|
+
from rch._rch_core import ( # type: ignore[import-not-found]
|
|
17
|
+
ObjectiveType,
|
|
18
|
+
NodeType,
|
|
19
|
+
Position,
|
|
20
|
+
Node,
|
|
21
|
+
Agent,
|
|
22
|
+
Options,
|
|
23
|
+
AssignmentConstraint,
|
|
24
|
+
TimeWindowConstraint,
|
|
25
|
+
Problem,
|
|
26
|
+
Result,
|
|
27
|
+
Solver,
|
|
28
|
+
solve_json_file as _solve_json_file,
|
|
29
|
+
solve_json_str as _solve_json_str,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------- #
|
|
34
|
+
# Convenience wrapper #
|
|
35
|
+
# --------------------------------------------------------------------- #
|
|
36
|
+
|
|
37
|
+
def _normalize_problem_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
38
|
+
"""Fill in Python-side defaults expected by the C++ JSON loader."""
|
|
39
|
+
normalized = _json.loads(_json.dumps(data))
|
|
40
|
+
for node in normalized.get("nodes", []):
|
|
41
|
+
node.setdefault("z", 0.0)
|
|
42
|
+
return normalized
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _solve_from_json_data(data: Dict[str, Any], time_limit: float) -> Dict[str, Any]:
|
|
46
|
+
normalized = _normalize_problem_data(data)
|
|
47
|
+
return _solve_json_str(_json.dumps(normalized), time_limit)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _looks_like_json_text(source: str) -> bool:
|
|
51
|
+
stripped = source.lstrip()
|
|
52
|
+
return bool(stripped) and stripped[0] in "[{"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def solve(
|
|
56
|
+
source: Union[str, pathlib.Path, dict],
|
|
57
|
+
*,
|
|
58
|
+
time_limit: float = -1,
|
|
59
|
+
) -> Dict[str, Any]:
|
|
60
|
+
"""Solve an MTSP instance and return the result as a plain dict.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
source : str | pathlib.Path | dict
|
|
65
|
+
- If a *str* or *Path* that points to an existing file, it is
|
|
66
|
+
treated as a JSON file path.
|
|
67
|
+
- If a *str* that does **not** point to an existing file, it is
|
|
68
|
+
treated as a raw JSON string.
|
|
69
|
+
- If a *dict*, it is serialized to JSON and solved.
|
|
70
|
+
time_limit : float, optional
|
|
71
|
+
Override the time limit in the JSON options (seconds). A value
|
|
72
|
+
<= 0 means "use whatever the JSON says".
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
dict
|
|
77
|
+
``{"status": "success"|"failed",
|
|
78
|
+
"timeout": bool,
|
|
79
|
+
"routes": [...],
|
|
80
|
+
"statistics": {...},
|
|
81
|
+
"anytime": [...]}``
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(source, dict):
|
|
84
|
+
return _solve_from_json_data(source, time_limit)
|
|
85
|
+
|
|
86
|
+
if isinstance(source, pathlib.Path):
|
|
87
|
+
return _solve_from_json_data(_json.loads(source.read_text()), time_limit)
|
|
88
|
+
|
|
89
|
+
text = str(source)
|
|
90
|
+
if _looks_like_json_text(text):
|
|
91
|
+
return _solve_from_json_data(_json.loads(text), time_limit)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
path = pathlib.Path(text)
|
|
95
|
+
if path.is_file():
|
|
96
|
+
return _solve_from_json_data(_json.loads(path.read_text()), time_limit)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
# Fall back to treating the input as raw JSON text.
|
|
101
|
+
return _solve_from_json_data(_json.loads(text), time_limit)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _result_to_dict(result: Result, problem: Problem, solve_time: float) -> Dict[str, Any]:
|
|
105
|
+
routes: List[Dict[str, Any]] = []
|
|
106
|
+
max_cost = 0.0
|
|
107
|
+
sum_cost = 0.0
|
|
108
|
+
|
|
109
|
+
for agent in problem.agents_ordered():
|
|
110
|
+
path = list(result.paths.get(agent.id, []))
|
|
111
|
+
cost = float(result.times.get(agent.id, 0.0))
|
|
112
|
+
routes.append({"agent_id": agent.id, "path": path, "cost": cost})
|
|
113
|
+
max_cost = max(max_cost, cost)
|
|
114
|
+
sum_cost += cost
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"status": "success" if result.paths else "failed",
|
|
118
|
+
"timeout": result.timeout,
|
|
119
|
+
"routes": routes,
|
|
120
|
+
"statistics": {
|
|
121
|
+
"solve_time": solve_time,
|
|
122
|
+
"max_cost": max_cost,
|
|
123
|
+
"sum_cost": sum_cost,
|
|
124
|
+
"n_generated": result.n_generated,
|
|
125
|
+
"n_expanded": result.n_expanded,
|
|
126
|
+
"last_update_time": result.last_update_time,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _anytime_to_list(
|
|
132
|
+
history: Sequence[Tuple[float, Result]], *, min_sum: bool
|
|
133
|
+
) -> List[Dict[str, float]]:
|
|
134
|
+
out: List[Dict[str, float]] = []
|
|
135
|
+
best = float("inf")
|
|
136
|
+
for t, res in history:
|
|
137
|
+
max_cost = 0.0
|
|
138
|
+
sum_cost = 0.0
|
|
139
|
+
for cost in res.times.values():
|
|
140
|
+
max_cost = max(max_cost, float(cost))
|
|
141
|
+
sum_cost += float(cost)
|
|
142
|
+
obj = sum_cost if min_sum else max_cost
|
|
143
|
+
if obj < best - 1e-9:
|
|
144
|
+
best = obj
|
|
145
|
+
out.append({"time": float(t), "max_cost": max_cost, "sum_cost": sum_cost})
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Planner:
|
|
150
|
+
"""Object-style builder API for constructing and solving MTSP problems."""
|
|
151
|
+
|
|
152
|
+
def __init__(self) -> None:
|
|
153
|
+
self.problem = Problem()
|
|
154
|
+
self._assignment = AssignmentConstraint()
|
|
155
|
+
self._timewindow = TimeWindowConstraint()
|
|
156
|
+
|
|
157
|
+
def add_depot(self, node_id: int, *, x: float, y: float, z: float = 0.0) -> "Planner":
|
|
158
|
+
node = Node()
|
|
159
|
+
node.id = node_id
|
|
160
|
+
node.position.x = x
|
|
161
|
+
node.position.y = y
|
|
162
|
+
node.position.z = z
|
|
163
|
+
node.type = NodeType.DEPOT
|
|
164
|
+
self.problem.add_node(node)
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def add_target(
|
|
168
|
+
self,
|
|
169
|
+
node_id: int,
|
|
170
|
+
*,
|
|
171
|
+
x: float,
|
|
172
|
+
y: float,
|
|
173
|
+
z: float = 0.0,
|
|
174
|
+
demand: float = 0.0,
|
|
175
|
+
) -> "Planner":
|
|
176
|
+
node = Node()
|
|
177
|
+
node.id = node_id
|
|
178
|
+
node.position.x = x
|
|
179
|
+
node.position.y = y
|
|
180
|
+
node.position.z = z
|
|
181
|
+
node.type = NodeType.TARGET
|
|
182
|
+
node.demand = demand
|
|
183
|
+
self.problem.add_node(node)
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def add_agent(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
agent_id: int,
|
|
190
|
+
agent_type: str,
|
|
191
|
+
start_node: int,
|
|
192
|
+
end_node: int,
|
|
193
|
+
order: Optional[int] = None,
|
|
194
|
+
capacity_limit: float = -1.0,
|
|
195
|
+
time_limit: float = -1.0,
|
|
196
|
+
) -> "Planner":
|
|
197
|
+
agent = Agent()
|
|
198
|
+
agent.id = agent_id
|
|
199
|
+
agent.type = agent_type
|
|
200
|
+
agent.start_node = start_node
|
|
201
|
+
agent.end_node = end_node
|
|
202
|
+
agent.capacity_limit = capacity_limit
|
|
203
|
+
agent.time_limit = time_limit
|
|
204
|
+
self.problem.add_agent(agent, agent_id if order is None else order)
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def set_cost_matrix(
|
|
208
|
+
self, agent_type: str, matrix: Sequence[Sequence[float]]
|
|
209
|
+
) -> "Planner":
|
|
210
|
+
self.problem.set_cost_matrix(agent_type, matrix)
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def add_assignment(self, node_id: int, types: Iterable[str]) -> "Planner":
|
|
214
|
+
self._assignment.set_assignment(node_id, list(types))
|
|
215
|
+
self.problem.set_assignment_constraint(self._assignment)
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def add_timewindow(self, node_id: int, start: float, end: float) -> "Planner":
|
|
219
|
+
self._timewindow.set_timewindow(node_id, start, end)
|
|
220
|
+
self.problem.set_timewindow_constraint(self._timewindow)
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def add_time_window(self, node_id: int, start: float, end: float) -> "Planner":
|
|
224
|
+
"""Alias of add_timewindow()."""
|
|
225
|
+
return self.add_timewindow(node_id, start, end)
|
|
226
|
+
|
|
227
|
+
def set_options(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
return_to_end: Optional[bool] = None,
|
|
231
|
+
objective: Optional[Union[str, ObjectiveType]] = None,
|
|
232
|
+
time_limit: Optional[float] = None,
|
|
233
|
+
) -> "Planner":
|
|
234
|
+
opts = self.problem.options()
|
|
235
|
+
if return_to_end is not None:
|
|
236
|
+
opts.return_to_end = return_to_end
|
|
237
|
+
if objective is not None:
|
|
238
|
+
if isinstance(objective, str):
|
|
239
|
+
key = objective.strip().lower()
|
|
240
|
+
if key in ("min_max", "minmax"):
|
|
241
|
+
opts.objective = ObjectiveType.MinMax
|
|
242
|
+
elif key in ("min_sum", "minsum"):
|
|
243
|
+
opts.objective = ObjectiveType.MinSum
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError("objective must be one of: min_max, min_sum")
|
|
246
|
+
else:
|
|
247
|
+
opts.objective = objective
|
|
248
|
+
if time_limit is not None:
|
|
249
|
+
opts.time_limit = time_limit
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def solve(self) -> Dict[str, Any]:
|
|
253
|
+
options = self.problem.options()
|
|
254
|
+
t0 = _time.perf_counter()
|
|
255
|
+
solver = Solver(self.problem, options)
|
|
256
|
+
solver.solve()
|
|
257
|
+
solve_time = _time.perf_counter() - t0
|
|
258
|
+
|
|
259
|
+
result = solver.get_result()
|
|
260
|
+
out = _result_to_dict(result, self.problem, solve_time)
|
|
261
|
+
out["anytime"] = _anytime_to_list(
|
|
262
|
+
solver.get_result_process(),
|
|
263
|
+
min_sum=(options.objective == ObjectiveType.MinSum),
|
|
264
|
+
)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
# ── Visualization convenience methods ────────────────────────────
|
|
268
|
+
|
|
269
|
+
def show_map(self, **kwargs):
|
|
270
|
+
"""Plot depots, targets and agent start positions.
|
|
271
|
+
|
|
272
|
+
Keyword arguments are forwarded to :func:`rch.show_map`.
|
|
273
|
+
"""
|
|
274
|
+
from rch._viz import show_map as _show_map
|
|
275
|
+
return _show_map(self, **kwargs)
|
|
276
|
+
|
|
277
|
+
def show_result(self, result: Dict[str, Any], **kwargs):
|
|
278
|
+
"""Plot solved routes on the map.
|
|
279
|
+
|
|
280
|
+
If ``return_to_end=False``, the trailing depot node appended by
|
|
281
|
+
the solver is automatically removed from the visualization.
|
|
282
|
+
|
|
283
|
+
Keyword arguments are forwarded to :func:`rch.show_result`.
|
|
284
|
+
"""
|
|
285
|
+
from rch._viz import show_result as _show_result
|
|
286
|
+
return _show_result(self, result, **kwargs)
|
|
Binary file
|
rch/_viz.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization helpers for the RCH MTSP Solver.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- show_map(planner) : plot depots and targets with agent start positions.
|
|
6
|
+
- show_result(planner, result) : plot solved routes on the map.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from rch._api import Planner
|
|
15
|
+
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Colour palette (tab10-derived, easy to distinguish) ──────────────
|
|
20
|
+
_AGENT_COLORS = [
|
|
21
|
+
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728",
|
|
22
|
+
"#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
|
|
23
|
+
"#bcbd22", "#17becf",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def _color_for(idx: int) -> str:
|
|
27
|
+
return _AGENT_COLORS[idx % len(_AGENT_COLORS)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Internal: collect node info from a Planner ───────────────────────
|
|
31
|
+
def _collect_nodes(planner: "Planner"):
|
|
32
|
+
"""Return (depots, targets) as lists of (id, x, y)."""
|
|
33
|
+
from rch._rch_core import NodeType # type: ignore[import-not-found]
|
|
34
|
+
|
|
35
|
+
depots, targets = [], []
|
|
36
|
+
for node in planner.problem.nodes():
|
|
37
|
+
pos = node.position
|
|
38
|
+
entry = (node.id, pos.x, pos.y)
|
|
39
|
+
if node.type == NodeType.DEPOT:
|
|
40
|
+
depots.append(entry)
|
|
41
|
+
else:
|
|
42
|
+
targets.append(entry)
|
|
43
|
+
return depots, targets
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _collect_agents(planner: "Planner"):
|
|
47
|
+
"""Return list of (id, type, start_node, end_node)."""
|
|
48
|
+
agents = []
|
|
49
|
+
for a in planner.problem.agents_ordered():
|
|
50
|
+
agents.append((a.id, a.type, a.start_node, a.end_node))
|
|
51
|
+
return agents
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _node_pos_map(planner: "Planner") -> Dict[int, tuple]:
|
|
55
|
+
"""node_id → (x, y)"""
|
|
56
|
+
return {
|
|
57
|
+
n.id: (n.position.x, n.position.y)
|
|
58
|
+
for n in planner.problem.nodes()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Public API ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def show_map(
|
|
65
|
+
planner: "Planner",
|
|
66
|
+
*,
|
|
67
|
+
ax: Optional[plt.Axes] = None,
|
|
68
|
+
figsize: tuple = (8, 6),
|
|
69
|
+
show: bool = True,
|
|
70
|
+
) -> plt.Axes:
|
|
71
|
+
"""Plot the problem map: depots (★), targets (●), and agent start positions.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
planner : rch.Planner
|
|
76
|
+
A planner instance with nodes and agents already added.
|
|
77
|
+
ax : matplotlib Axes, optional
|
|
78
|
+
If provided, draw on this axes; otherwise create a new figure.
|
|
79
|
+
figsize : tuple
|
|
80
|
+
Figure size when creating a new figure.
|
|
81
|
+
show : bool
|
|
82
|
+
If True, call ``plt.show()`` at the end (set False to compose plots).
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
matplotlib.axes.Axes
|
|
87
|
+
"""
|
|
88
|
+
depots, targets = _collect_nodes(planner)
|
|
89
|
+
agents = _collect_agents(planner)
|
|
90
|
+
pos_map = _node_pos_map(planner)
|
|
91
|
+
|
|
92
|
+
if ax is None:
|
|
93
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
94
|
+
|
|
95
|
+
# Draw targets
|
|
96
|
+
if targets:
|
|
97
|
+
tx = [t[1] for t in targets]
|
|
98
|
+
ty = [t[2] for t in targets]
|
|
99
|
+
ax.scatter(tx, ty, c="grey", s=60, zorder=3, label="Target")
|
|
100
|
+
for nid, x, y in targets:
|
|
101
|
+
ax.annotate(str(nid), (x, y), textcoords="offset points",
|
|
102
|
+
xytext=(5, 5), fontsize=8, color="grey")
|
|
103
|
+
|
|
104
|
+
# Draw depots
|
|
105
|
+
if depots:
|
|
106
|
+
dx = [d[1] for d in depots]
|
|
107
|
+
dy = [d[2] for d in depots]
|
|
108
|
+
ax.scatter(dx, dy, c="black", marker="*", s=200, zorder=4, label="Depot")
|
|
109
|
+
for nid, x, y in depots:
|
|
110
|
+
ax.annotate(str(nid), (x, y), textcoords="offset points",
|
|
111
|
+
xytext=(5, 5), fontsize=9, fontweight="bold")
|
|
112
|
+
|
|
113
|
+
# Draw agent start positions (small markers next to depot)
|
|
114
|
+
for idx, (aid, atype, start, end) in enumerate(agents):
|
|
115
|
+
if start in pos_map:
|
|
116
|
+
sx, sy = pos_map[start]
|
|
117
|
+
color = _color_for(idx)
|
|
118
|
+
ax.scatter([sx], [sy], marker="^", c=color, s=100, zorder=5,
|
|
119
|
+
edgecolors="black", linewidths=0.6,
|
|
120
|
+
label=f"Agent {aid} ({atype})")
|
|
121
|
+
|
|
122
|
+
ax.set_xlabel("X")
|
|
123
|
+
ax.set_ylabel("Y")
|
|
124
|
+
ax.legend(fontsize=9, loc="best")
|
|
125
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
126
|
+
ax.grid(True, alpha=0.3)
|
|
127
|
+
|
|
128
|
+
if show:
|
|
129
|
+
plt.tight_layout()
|
|
130
|
+
plt.show()
|
|
131
|
+
return ax
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def show_result(
|
|
135
|
+
planner: "Planner",
|
|
136
|
+
result: Dict[str, Any],
|
|
137
|
+
*,
|
|
138
|
+
ax: Optional[plt.Axes] = None,
|
|
139
|
+
figsize: tuple = (8, 6),
|
|
140
|
+
show: bool = True,
|
|
141
|
+
) -> plt.Axes:
|
|
142
|
+
"""Plot solved routes on the problem map.
|
|
143
|
+
|
|
144
|
+
Each agent's path is drawn with a distinct colour and optional arrows.
|
|
145
|
+
If ``return_to_end`` is ``False``, the final node in each route
|
|
146
|
+
(which is the depot added by the solver) is stripped, and no edge
|
|
147
|
+
is drawn back to that depot.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
planner : rch.Planner
|
|
152
|
+
The planner used to solve the problem.
|
|
153
|
+
result : dict
|
|
154
|
+
The result dict returned by ``planner.solve()``.
|
|
155
|
+
ax : matplotlib Axes, optional
|
|
156
|
+
figsize : tuple
|
|
157
|
+
show : bool
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
matplotlib.axes.Axes
|
|
162
|
+
"""
|
|
163
|
+
depots, targets = _collect_nodes(planner)
|
|
164
|
+
agents = _collect_agents(planner)
|
|
165
|
+
pos_map = _node_pos_map(planner)
|
|
166
|
+
|
|
167
|
+
# Detect return_to_end setting
|
|
168
|
+
return_to_end = planner.problem.options().return_to_end
|
|
169
|
+
|
|
170
|
+
if ax is None:
|
|
171
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
172
|
+
|
|
173
|
+
# Draw targets
|
|
174
|
+
if targets:
|
|
175
|
+
tx = [t[1] for t in targets]
|
|
176
|
+
ty = [t[2] for t in targets]
|
|
177
|
+
ax.scatter(tx, ty, c="grey", s=60, zorder=3, label="Target")
|
|
178
|
+
for nid, x, y in targets:
|
|
179
|
+
ax.annotate(str(nid), (x, y), textcoords="offset points",
|
|
180
|
+
xytext=(5, 5), fontsize=8, color="grey")
|
|
181
|
+
|
|
182
|
+
# Draw depots
|
|
183
|
+
if depots:
|
|
184
|
+
dx = [d[1] for d in depots]
|
|
185
|
+
dy = [d[2] for d in depots]
|
|
186
|
+
ax.scatter(dx, dy, c="black", marker="*", s=200, zorder=4, label="Depot")
|
|
187
|
+
for nid, x, y in depots:
|
|
188
|
+
ax.annotate(str(nid), (x, y), textcoords="offset points",
|
|
189
|
+
xytext=(5, 5), fontsize=9, fontweight="bold")
|
|
190
|
+
|
|
191
|
+
# Build depot-id set for trimming
|
|
192
|
+
depot_ids = {d[0] for d in depots}
|
|
193
|
+
|
|
194
|
+
# Draw each agent's route
|
|
195
|
+
routes = result.get("routes", [])
|
|
196
|
+
for idx, route in enumerate(routes):
|
|
197
|
+
aid = route["agent_id"]
|
|
198
|
+
path = list(route["path"])
|
|
199
|
+
cost = route.get("cost", 0.0)
|
|
200
|
+
|
|
201
|
+
if not path:
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
# If return_to_end=False, the solver still appends the depot as
|
|
205
|
+
# the last node; remove it so the visualization doesn't draw
|
|
206
|
+
# the return edge.
|
|
207
|
+
if not return_to_end and len(path) > 1 and path[-1] in depot_ids:
|
|
208
|
+
path = path[:-1]
|
|
209
|
+
|
|
210
|
+
color = _color_for(idx)
|
|
211
|
+
|
|
212
|
+
# Collect (x, y) coordinates along the path
|
|
213
|
+
xs = [pos_map[nid][0] for nid in path if nid in pos_map]
|
|
214
|
+
ys = [pos_map[nid][1] for nid in path if nid in pos_map]
|
|
215
|
+
|
|
216
|
+
if len(xs) < 2:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
# Draw path with arrows
|
|
220
|
+
ax.plot(xs, ys, color=color, linewidth=2, alpha=0.8, zorder=2,
|
|
221
|
+
label=f"Agent {aid} (cost={cost:.2f})")
|
|
222
|
+
|
|
223
|
+
# Draw arrows along each segment
|
|
224
|
+
for i in range(len(xs) - 1):
|
|
225
|
+
dx = xs[i + 1] - xs[i]
|
|
226
|
+
dy = ys[i + 1] - ys[i]
|
|
227
|
+
ax.annotate(
|
|
228
|
+
"", xy=(xs[i + 1], ys[i + 1]),
|
|
229
|
+
xytext=(xs[i], ys[i]),
|
|
230
|
+
arrowprops=dict(arrowstyle="->", color=color, lw=1.5),
|
|
231
|
+
zorder=2,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
ax.set_xlabel("X")
|
|
235
|
+
ax.set_ylabel("Y")
|
|
236
|
+
ax.legend(fontsize=9, loc="best")
|
|
237
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
238
|
+
ax.grid(True, alpha=0.3)
|
|
239
|
+
|
|
240
|
+
if show:
|
|
241
|
+
plt.tight_layout()
|
|
242
|
+
plt.show()
|
|
243
|
+
return ax
|