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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: scikit-build-core 0.12.2
3
+ Root-Is-Purelib: false
4
+ Tag: cp310-cp310-win_amd64
5
+
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