python-motion-planning 2.0.1__tar.gz → 2.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.
Files changed (71) hide show
  1. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/PKG-INFO +2 -2
  2. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/pyproject.toml +2 -2
  3. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/map/grid.py +241 -85
  4. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/utils/geometry.py +3 -2
  5. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/lazy_theta_star.py +15 -7
  6. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/theta_star.py +4 -3
  7. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/sample_search/rrt.py +1 -1
  8. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning.egg-info/PKG-INFO +2 -2
  9. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning.egg-info/SOURCES.txt +3 -1
  10. python_motion_planning-2.1/tests/test_anya.py +67 -0
  11. python_motion_planning-2.1/tests/test_grid_line_symmetry.py +81 -0
  12. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/LICENSE +0 -0
  13. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/README.md +0 -0
  14. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/setup.cfg +0 -0
  15. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/__init__.py +0 -0
  16. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/__init__.py +0 -0
  17. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/__init__.py +0 -0
  18. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/map/__init__.py +0 -0
  19. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/map/base_map.py +0 -0
  20. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/node.py +0 -0
  21. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/robot/__init__.py +0 -0
  22. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/robot/base_robot.py +0 -0
  23. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/robot/circular_robot.py +0 -0
  24. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/robot/diff_drive_robot.py +0 -0
  25. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/types.py +0 -0
  26. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/world/__init__.py +0 -0
  27. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/world/base_world.py +0 -0
  28. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/env/world/toy_simulator.py +0 -0
  29. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/utils/__init__.py +0 -0
  30. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/utils/child_tree.py +0 -0
  31. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/utils/frame_transformer.py +0 -0
  32. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/visualizer/__init__.py +0 -0
  33. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/visualizer/base_visualizer.py +0 -0
  34. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/visualizer/visualizer_2d.py +0 -0
  35. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/common/visualizer/visualizer_3d.py +0 -0
  36. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/__init__.py +0 -0
  37. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/base_controller.py +0 -0
  38. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/__init__.py +0 -0
  39. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/apf.py +0 -0
  40. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/dwa.py +0 -0
  41. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/path_tracker.py +0 -0
  42. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/pid.py +0 -0
  43. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/pure_pursuit.py +0 -0
  44. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/path_tracker/rpp.py +0 -0
  45. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/controller/random_controller.py +0 -0
  46. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/__init__.py +0 -0
  47. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/base_path_planner.py +0 -0
  48. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/__init__.py +0 -0
  49. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/a_star.py +0 -0
  50. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/dijkstra.py +0 -0
  51. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/gbfs.py +0 -0
  52. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/graph_search/jps.py +0 -0
  53. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/hybrid_search/__init__.py +0 -0
  54. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/hybrid_search/voronoi_planner.py +0 -0
  55. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/sample_search/__init__.py +0 -0
  56. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/sample_search/rrt_connect.py +0 -0
  57. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/path_planner/sample_search/rrt_star.py +0 -0
  58. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/__init__.py +0 -0
  59. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/base_curve_generator.py +0 -0
  60. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/__init__.py +0 -0
  61. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/point_based/__init__.py +0 -0
  62. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/point_based/bspline.py +0 -0
  63. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/point_based/cubic_spline.py +0 -0
  64. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/pose_based/__init__.py +0 -0
  65. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/pose_based/bezier.py +0 -0
  66. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/pose_based/dubins.py +0 -0
  67. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/pose_based/polynomial.py +0 -0
  68. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning/traj_optimizer/curve_generator/pose_based/reeds_shepp.py +0 -0
  69. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning.egg-info/dependency_links.txt +0 -0
  70. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning.egg-info/requires.txt +0 -0
  71. {python_motion_planning-2.0.1 → python_motion_planning-2.1}/src/python_motion_planning.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-motion-planning
3
- Version: 2.0.1
3
+ Version: 2.1
4
4
  Summary: Motion planning algorithms for Python
5
5
  Maintainer-email: Wu Maojia <omige@mail.nwpu.edu.cn>, Yang Haodong <913982779@qq.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -681,7 +681,7 @@ Project-URL: Repository, https://github.com/ai-winter/python_motion_planning
681
681
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
682
682
  Classifier: Programming Language :: Python :: 3
683
683
  Classifier: Programming Language :: Python :: 3 :: Only
684
- Requires-Python: >=3.6
684
+ Requires-Python: >=3.8
685
685
  Description-Content-Type: text/markdown
686
686
  License-File: LICENSE
687
687
  Requires-Dist: numpy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-motion-planning"
7
- version = "2.0.1"
7
+ version = "2.1"
8
8
  description = "Motion planning algorithms for Python"
9
9
  maintainers = [
10
10
  {name = "Wu Maojia", email = "omige@mail.nwpu.edu.cn"},
@@ -12,7 +12,7 @@ maintainers = [
12
12
  ]
13
13
  readme = "README.md"
14
14
  license = {file = "LICENSE"}
15
- requires-python = ">=3.6"
15
+ requires-python = ">=3.8"
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "numba",
@@ -1,11 +1,10 @@
1
1
  """
2
2
  @file: grid.py
3
3
  @author: Wu Maojia
4
- @update: 2026.6.2
4
+ @update: 2026.9.11
5
5
  """
6
6
  from itertools import product
7
- from typing import Iterable, Union, Tuple, Callable, List, Dict
8
- import math
7
+ from typing import Iterable, Union, Tuple, List, Dict
9
8
  import time
10
9
 
11
10
  import numpy as np
@@ -60,57 +59,77 @@ def _grid_is_expandable(
60
59
  esdf: np.ndarray,
61
60
  obstacle_type: int,
62
61
  inflation_type: int,
62
+ strict_collision: bool,
63
63
  ) -> bool:
64
64
  if not _grid_within_bounds(point, shape):
65
65
  return False
66
66
 
67
67
  point_idx = _grid_flat_index(point, shape)
68
+ src_idx = 0
69
+ src_is_inflation = False
68
70
  if has_src_point:
69
71
  src_idx = _grid_flat_index(src_point, shape)
70
- if type_map[src_idx] == inflation_type and esdf[point_idx] >= esdf[src_idx]:
71
- return True
72
+ src_is_inflation = type_map[src_idx] == inflation_type
72
73
 
73
74
  point_type = type_map[point_idx]
74
- return point_type != obstacle_type and point_type != inflation_type
75
+ if point_type == obstacle_type or (
76
+ point_type == inflation_type
77
+ and not (src_is_inflation and esdf[point_idx] >= esdf[src_idx])
78
+ ):
79
+ return False
75
80
 
81
+ if not has_src_point or not strict_collision:
82
+ return True
76
83
 
77
- @_njit(cache=True)
78
- def _grid_distance(p1: np.ndarray, p2: np.ndarray) -> float:
79
- dist_square = 0.0
80
- for d in range(p1.size):
81
- diff = p1[d] - p2[d]
82
- dist_square += diff * diff
83
- return math.sqrt(dist_square)
84
+ dim = point.size
85
+ if dim == 2:
86
+ dx = point[0] - src_point[0]
87
+ dy = point[1] - src_point[1]
88
+ if abs(dx) > 1 or abs(dy) > 1 or dx == 0 or dy == 0:
89
+ return True
84
90
 
91
+ side_idx = point[0] * shape[1] + src_point[1]
92
+ side_type = type_map[side_idx]
93
+ if side_type == obstacle_type or (
94
+ side_type == inflation_type
95
+ and not (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
96
+ ):
97
+ return False
85
98
 
86
- @_njit(cache=True)
87
- def _grid_map_to_world(point: np.ndarray, bounds: np.ndarray, resolution: float) -> np.ndarray:
88
- point_world = np.empty(point.size, dtype=np.float64)
89
- for d in range(point.size):
90
- point_world[d] = (point[d] + 0.5) * resolution + bounds[d, 0]
91
- return point_world
99
+ side_idx = src_point[0] * shape[1] + point[1]
100
+ side_type = type_map[side_idx]
101
+ return side_type != obstacle_type and (
102
+ side_type != inflation_type
103
+ or (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
104
+ )
92
105
 
106
+ changed_mask = 0
107
+ for d in range(dim):
108
+ diff = point[d] - src_point[d]
109
+ if abs(diff) > 1:
110
+ return True
111
+ if diff != 0:
112
+ changed_mask |= 1 << d
93
113
 
94
- @_njit(cache=True)
95
- def _grid_point_float_to_int(point: np.ndarray, shape: np.ndarray) -> np.ndarray:
96
- point_int = np.empty(shape.size, dtype=np.int64)
97
- for d in range(shape.size):
98
- value = int(round(point[d]))
99
- if value < 0:
100
- value = 0
101
- elif value >= shape[d]:
102
- value = shape[d] - 1
103
- point_int[d] = value
104
- return point_int
114
+ if not changed_mask & (changed_mask - 1):
115
+ return True
105
116
 
117
+ subset = (changed_mask - 1) & changed_mask
118
+ while subset:
119
+ side_idx = 0
120
+ for d in range(dim):
121
+ side = point[d] if subset & (1 << d) else src_point[d]
122
+ side_idx = side_idx * shape[d] + side
123
+
124
+ side_type = type_map[side_idx]
125
+ if side_type == obstacle_type or (
126
+ side_type == inflation_type
127
+ and not (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
128
+ ):
129
+ return False
130
+ subset = (subset - 1) & changed_mask
106
131
 
107
- @_njit(cache=True)
108
- def _grid_world_to_map_float(point: np.ndarray, bounds: np.ndarray, resolution: float) -> np.ndarray:
109
- point_map = np.empty(point.size, dtype=np.float64)
110
- inv_resolution = 1.0 / resolution
111
- for d in range(point.size):
112
- point_map[d] = (point[d] - bounds[d, 0]) * inv_resolution - 0.5
113
- return point_map
132
+ return True
114
133
 
115
134
 
116
135
  @_njit(cache=True)
@@ -166,7 +185,10 @@ def _grid_line_of_sight(p1: np.ndarray, p2: np.ndarray) -> np.ndarray:
166
185
  continue
167
186
 
168
187
  error[d] += delta2[d]
169
- if error[d] > abs_delta[primary_axis]:
188
+ # Reverse the tie-break when traversing the primary axis backwards.
189
+ if error[d] > abs_delta[primary_axis] or (
190
+ error[d] == abs_delta[primary_axis] and primary_step < 0
191
+ ):
170
192
  current[d] += 1 if delta[d] > 0 else -1
171
193
  error[d] -= delta2[primary_axis]
172
194
 
@@ -185,10 +207,11 @@ def _grid_in_collision(
185
207
  esdf: np.ndarray,
186
208
  obstacle_type: int,
187
209
  inflation_type: int,
210
+ strict_collision: bool,
188
211
  ) -> bool:
189
- if not _grid_is_expandable(p1, p1, False, shape, type_map, esdf, obstacle_type, inflation_type):
212
+ if not _grid_is_expandable(p1, p1, False, shape, type_map, esdf, obstacle_type, inflation_type, False):
190
213
  return True
191
- if not _grid_is_expandable(p2, p1, True, shape, type_map, esdf, obstacle_type, inflation_type):
214
+ if not _grid_is_expandable(p2, p1, True, shape, type_map, esdf, obstacle_type, inflation_type, False):
192
215
  return True
193
216
 
194
217
  dim = p1.size
@@ -231,11 +254,24 @@ def _grid_in_collision(
231
254
  continue
232
255
 
233
256
  error[d] += delta2[d]
234
- if error[d] > abs_delta[primary_axis]:
257
+ # Keep the same cells as _grid_line_of_sight in both directions.
258
+ if error[d] > abs_delta[primary_axis] or (
259
+ error[d] == abs_delta[primary_axis] and primary_step < 0
260
+ ):
235
261
  current[d] += 1 if delta[d] > 0 else -1
236
262
  error[d] -= delta2[primary_axis]
237
263
 
238
- if not _grid_is_expandable(current, last_point, True, shape, type_map, esdf, obstacle_type, inflation_type):
264
+ if not _grid_is_expandable(
265
+ current,
266
+ last_point,
267
+ True,
268
+ shape,
269
+ type_map,
270
+ esdf,
271
+ obstacle_type,
272
+ inflation_type,
273
+ strict_collision,
274
+ ):
239
275
  return True
240
276
 
241
277
  return False
@@ -250,6 +286,7 @@ def _grid_neighbor_positions_and_mask(
250
286
  esdf: np.ndarray,
251
287
  obstacle_type: int,
252
288
  inflation_type: int,
289
+ strict_collision: bool,
253
290
  ) -> Tuple[np.ndarray, np.ndarray]:
254
291
  node_num = offsets.shape[0]
255
292
  dim = offsets.shape[1]
@@ -262,7 +299,17 @@ def _grid_neighbor_positions_and_mask(
262
299
  neighbor[d] = current[d] + offsets[i, d]
263
300
  positions[i, d] = neighbor[d]
264
301
 
265
- mask[i] = _grid_is_expandable(neighbor, current, True, shape, type_map, esdf, obstacle_type, inflation_type)
302
+ mask[i] = _grid_is_expandable(
303
+ neighbor,
304
+ current,
305
+ True,
306
+ shape,
307
+ type_map,
308
+ esdf,
309
+ obstacle_type,
310
+ inflation_type,
311
+ strict_collision,
312
+ )
266
313
 
267
314
  return positions, mask
268
315
 
@@ -338,6 +385,7 @@ class GridTypeMap:
338
385
  self._data = np.asarray(type_map)
339
386
  self._shape = self._data.shape
340
387
  self._dtype = self._data.dtype
388
+ self._lazy_flags_ = 3
341
389
 
342
390
  self._dtype_options = [np.int8, np.int16, np.int32, np.int64]
343
391
  if self._dtype not in self._dtype_options:
@@ -354,6 +402,7 @@ class GridTypeMap:
354
402
 
355
403
  def __setitem__(self, idx, value):
356
404
  self._data[idx] = value
405
+ self._lazy_flags_ = 3
357
406
 
358
407
  @property
359
408
  def data(self) -> np.ndarray:
@@ -380,6 +429,7 @@ class Grid(BaseMap):
380
429
  resolution: resolution of the grid map
381
430
  type_map: initial type map of the grid map (its shape must be the same as the converted grid map shape, and its dtype must be int)
382
431
  inflation_radius: radius of the inflation
432
+ strict_collision: whether diagonal steps beside obstacles or inflation are collisions (default: True)
383
433
 
384
434
  Examples:
385
435
  >>> grid_map = Grid(bounds=[[0, 51], [0, 31]], resolution=0.5)
@@ -428,7 +478,7 @@ class Grid(BaseMap):
428
478
 
429
479
  >>> grid_map[1, 0] = TYPES.OBSTACLE # place an obstacle
430
480
  >>> grid_map.get_neighbors(Node((0, 0))) # limited within the bounds
431
- [Node((0, 1), (0, 0), 0, 0), Node((1, 1), (0, 0), 0, 0)]
481
+ [Node((0, 1), (0, 0), 0, 0)]
432
482
 
433
483
  >>> grid_map.get_neighbors(Node((grid_map.shape[0] - 1, grid_map.shape[1] - 1)), diagonal=False) # limited within the boundss
434
484
  [Node((100, 61), (101, 61), 0, 0), Node((101, 60), (101, 61), 0, 0)]
@@ -443,15 +493,31 @@ class Grid(BaseMap):
443
493
  False
444
494
 
445
495
  >>> grid_map[1, 3] = TYPES.OBSTACLE
446
- >>> grid_map.update_esdf()
447
496
  >>> grid_map.in_collision((1, 2), (3, 6))
448
497
  True
498
+
499
+ >>> grid_map = Grid(bounds=[[0, 3], [0, 3]])
500
+ >>> grid_map[1, 0] = TYPES.OBSTACLE
501
+ >>> grid_map.in_collision((0, 0), (1, 1))
502
+ True
503
+ >>> grid_map.strict_collision = False
504
+ >>> grid_map.in_collision((0, 0), (1, 1))
505
+ False
506
+
507
+ >>> grid_map = Grid(bounds=[[0, 4], [0, 4]])
508
+ >>> grid_map[2, :] = TYPES.OBSTACLE
509
+ >>> grid_map.is_connected((0, 0), (3, 3))
510
+ False
511
+ >>> grid_map[2, 1] = TYPES.FREE
512
+ >>> grid_map.is_connected((0, 0), (3, 3))
513
+ True
449
514
  """
450
515
  def __init__(self,
451
516
  bounds: Iterable = [[0, 30], [0, 40]],
452
517
  resolution: float = 1.0,
453
518
  type_map: Union[GridTypeMap, np.ndarray] = None,
454
519
  inflation_radius: float = 0.0,
520
+ strict_collision: bool = True,
455
521
  ) -> None:
456
522
  super().__init__(bounds)
457
523
 
@@ -459,15 +525,15 @@ class Grid(BaseMap):
459
525
  shape = tuple([int((self.bounds[i, 1] - self.bounds[i, 0]) / self.resolution) for i in range(self.dim)])
460
526
 
461
527
  if type_map is None:
462
- self.type_map = GridTypeMap(np.zeros(shape, dtype=np.int8))
528
+ self._type_map = GridTypeMap(np.zeros(shape, dtype=np.int8))
463
529
  else:
464
530
  if type_map.shape != shape:
465
531
  raise ValueError("Shape must be {} instead of {} with given bounds={} and resolution={}".format(shape, type_map.shape, self.bounds, self.resolution))
466
532
 
467
533
  if isinstance(type_map, GridTypeMap):
468
- self.type_map = type_map
534
+ self._type_map = type_map
469
535
  elif isinstance(type_map, np.ndarray):
470
- self.type_map = GridTypeMap(type_map)
536
+ self._type_map = GridTypeMap(type_map)
471
537
  else:
472
538
  raise ValueError("Type map must be GridTypeMap or numpy.ndarray instead of {}".format(type(type_map)))
473
539
 
@@ -475,7 +541,8 @@ class Grid(BaseMap):
475
541
  self._precompute_offsets()
476
542
 
477
543
  self._esdf = np.zeros(self.shape, dtype=np.float32)
478
- # self.update_esdf() # updated in self.inflate_obstacles()
544
+ self._connectivity_map = np.zeros(self.shape, dtype=np.int32)
545
+ self.strict_collision = strict_collision
479
546
 
480
547
  self.inflation_radius = inflation_radius
481
548
  if self.inflation_radius >= 1:
@@ -490,34 +557,79 @@ class Grid(BaseMap):
490
557
  @property
491
558
  def resolution(self) -> float:
492
559
  return self._resolution
560
+
561
+ @property
562
+ def type_map(self) -> GridTypeMap:
563
+ return self._type_map
493
564
 
494
565
  @property
495
566
  def shape(self) -> tuple:
496
- return self.type_map.shape
567
+ return self._type_map.shape
497
568
 
498
569
  @property
499
570
  def dtype(self) -> np.dtype:
500
- return self.type_map.dtype
571
+ return self._type_map.dtype
501
572
 
502
573
  @property
503
574
  def esdf(self) -> np.ndarray:
575
+ if self._esdf_lazy_flag_:
576
+ self.update_esdf()
577
+ self._esdf_lazy_flag_ = False
504
578
  return self._esdf
579
+
580
+ @property
581
+ def connectivity_map(self) -> np.ndarray:
582
+ if self._connectivity_lazy_flag_:
583
+ self.update_connectivity()
584
+ self._connectivity_lazy_flag_ = False
585
+ return self._connectivity_map
505
586
 
506
587
  @property
507
588
  def data(self) -> np.ndarray:
508
- return self.type_map.data
589
+ return self._type_map.data
509
590
 
510
591
  def __getitem__(self, idx):
511
- return self.type_map[idx]
592
+ return self._type_map[idx]
512
593
 
513
594
  def __setitem__(self, idx, value):
514
- self.type_map[idx] = value
595
+ self._type_map[idx] = value
596
+
597
+ @property
598
+ def _esdf_lazy_flag_(self) -> bool:
599
+ return bool(self._type_map._lazy_flags_ & 1)
600
+
601
+ @_esdf_lazy_flag_.setter
602
+ def _esdf_lazy_flag_(self, value: bool) -> None:
603
+ if value:
604
+ self._type_map._lazy_flags_ |= 1
605
+ else:
606
+ self._type_map._lazy_flags_ &= ~1
607
+
608
+ @property
609
+ def _connectivity_lazy_flag_(self) -> bool:
610
+ return bool(self._type_map._lazy_flags_ & 2)
611
+
612
+ @_connectivity_lazy_flag_.setter
613
+ def _connectivity_lazy_flag_(self, value: bool) -> None:
614
+ if value:
615
+ self._type_map._lazy_flags_ |= 2
616
+ else:
617
+ self._type_map._lazy_flags_ &= ~2
618
+
619
+ @property
620
+ def strict_collision(self) -> bool:
621
+ return self._strict_collision
622
+
623
+ @strict_collision.setter
624
+ def strict_collision(self, value: bool) -> None:
625
+ self._strict_collision = value
626
+ self._connectivity_lazy_flag_ = True
515
627
 
516
628
  def _type_map_flat(self) -> np.ndarray:
517
- return np.ravel(self.type_map.data)
629
+ return np.ravel(self._type_map.data)
518
630
 
519
631
  def _esdf_flat(self) -> np.ndarray:
520
- return np.ravel(self._esdf)
632
+ return np.ravel(self.esdf)
521
633
 
522
634
  def map_to_world(self, point: tuple) -> Tuple[float, ...]:
523
635
  """
@@ -532,8 +644,9 @@ class Grid(BaseMap):
532
644
  if len(point) != self.dim:
533
645
  raise ValueError("Point dimension does not match map dimension.")
534
646
 
535
- point_world = _grid_map_to_world(np.asarray(point, dtype=np.float64), self.bounds, self.resolution)
536
- return tuple(float(x) for x in point_world)
647
+ bounds = self.bounds
648
+ resolution = self.resolution
649
+ return tuple(float((point[d] + 0.5) * resolution + bounds[d, 0]) for d in range(self.dim))
537
650
 
538
651
  def world_to_map(self, point: Tuple[float, ...], discrete: bool = True) -> tuple:
539
652
  """
@@ -549,13 +662,13 @@ class Grid(BaseMap):
549
662
  if len(point) != self.dim:
550
663
  raise ValueError("Point dimension does not match map dimension.")
551
664
 
552
- point_array = np.asarray(point, dtype=np.float64)
553
665
  if discrete:
554
- point_map = _grid_world_to_map_int(point_array, self.bounds, self.resolution, self._shape_array)
666
+ point_map = _grid_world_to_map_int(np.asarray(point, dtype=np.float64), self.bounds, self.resolution, self._shape_array)
555
667
  return tuple(int(x) for x in point_map)
556
668
  else:
557
- point_map = _grid_world_to_map_float(point_array, self.bounds, self.resolution)
558
- return tuple(float(x) for x in point_map)
669
+ inv_resolution = 1.0 / self.resolution
670
+ bounds = self.bounds
671
+ return tuple(float((point[d] - bounds[d, 0]) * inv_resolution - 0.5) for d in range(self.dim))
559
672
 
560
673
  def get_distance(self, p1: Tuple[int, int], p2: Tuple[int, int]) -> float:
561
674
  """
@@ -568,9 +681,7 @@ class Grid(BaseMap):
568
681
  Returns:
569
682
  dist: Distance between two points.
570
683
  """
571
- if len(p1) != len(p2):
572
- raise ValueError("Dimension mismatch")
573
- return _grid_distance(np.asarray(p1, dtype=np.float64), np.asarray(p2, dtype=np.float64))
684
+ return Geometry.dist(p1, p2, type='Euclidean')
574
685
 
575
686
  def within_bounds(self, point: Tuple[int, ...]) -> bool:
576
687
  """
@@ -582,11 +693,31 @@ class Grid(BaseMap):
582
693
  Returns:
583
694
  bool: True if the point is within the bounds of the map, False otherwise.
584
695
  """
585
- # if point.dim != self.dim:
586
- # raise ValueError("Point dimension does not match map dimension.")
696
+ if len(point) != self.dim:
697
+ return False
698
+ shape = self.shape
699
+ for d in range(self.dim):
700
+ if point[d] < 0 or point[d] >= shape[d]:
701
+ return False
702
+ return True
703
+
704
+ def is_connected(self, p1: Tuple[int, ...], p2: Tuple[int, ...]) -> bool:
705
+ """
706
+ Check whether two points belong to the same free-space component.
707
+
708
+ Args:
709
+ p1: First point.
710
+ p2: Second point.
711
+
712
+ Returns:
713
+ connected: True if the two points are connected, False otherwise.
714
+ """
715
+ if not self.within_bounds(p1) or not self.within_bounds(p2):
716
+ raise ValueError("Points are out of bounds or invalid.")
587
717
 
588
- # return all(0 <= point[i] < self.shape[i] for i in range(self.dim))
589
- return _grid_within_bounds(np.asarray(point, dtype=np.int64), self._shape_array)
718
+ connectivity_map = self.connectivity_map
719
+ component = connectivity_map[p1]
720
+ return component != 0 and component == connectivity_map[p2]
590
721
 
591
722
  def is_expandable(self, point: Tuple[int, ...], src_point: Tuple[int, ...] = None) -> bool:
592
723
  """
@@ -612,6 +743,7 @@ class Grid(BaseMap):
612
743
  self._esdf_flat(),
613
744
  TYPES.OBSTACLE,
614
745
  TYPES.INFLATION,
746
+ self.strict_collision,
615
747
  )
616
748
 
617
749
  def get_neighbors(self,
@@ -631,6 +763,16 @@ class Grid(BaseMap):
631
763
  if node.dim != self.dim:
632
764
  raise ValueError("Node dimension does not match map dimension.")
633
765
 
766
+ positions, mask = self._get_neighbor_arrays(node, diagonal)
767
+
768
+ return [
769
+ Node(tuple(positions[i].tolist()), node.current, node.g, node.h)
770
+ for i in range(positions.shape[0])
771
+ if mask[i]
772
+ ]
773
+
774
+ def _get_neighbor_arrays(self, node: Node, diagonal: bool = True) -> Tuple[np.ndarray, np.ndarray]:
775
+ """Get candidate neighbor positions and their expandable mask."""
634
776
  offsets = self._diagonal_offsets_array if diagonal else self._orthogonal_offsets_array
635
777
  positions, mask = _grid_neighbor_positions_and_mask(
636
778
  np.asarray(node.current, dtype=np.int64),
@@ -640,13 +782,9 @@ class Grid(BaseMap):
640
782
  self._esdf_flat(),
641
783
  TYPES.OBSTACLE,
642
784
  TYPES.INFLATION,
785
+ self.strict_collision,
643
786
  )
644
-
645
- return [
646
- Node(tuple(int(x) for x in positions[i]), node.current, node.g, node.h)
647
- for i in range(positions.shape[0])
648
- if mask[i]
649
- ]
787
+ return positions, mask
650
788
 
651
789
  def line_of_sight(self, p1: Tuple[int, ...], p2: Tuple[int, ...]) -> List[Tuple[int, ...]]:
652
790
  """
@@ -691,6 +829,7 @@ class Grid(BaseMap):
691
829
  self._esdf_flat(),
692
830
  TYPES.OBSTACLE,
693
831
  TYPES.INFLATION,
832
+ self.strict_collision,
694
833
  )
695
834
 
696
835
  def fill_boundary_with_obstacles(self) -> None:
@@ -702,12 +841,12 @@ class Grid(BaseMap):
702
841
  # First boundary (start index)
703
842
  slices_start = [slice(None)] * self.dim
704
843
  slices_start[d] = 0
705
- self.type_map[tuple(slices_start)] = TYPES.OBSTACLE
844
+ self._type_map[tuple(slices_start)] = TYPES.OBSTACLE
706
845
 
707
846
  # Last boundary (end index)
708
847
  slices_end = [slice(None)] * self.dim
709
848
  slices_end[d] = -1
710
- self.type_map[tuple(slices_end)] = TYPES.OBSTACLE
849
+ self._type_map[tuple(slices_end)] = TYPES.OBSTACLE
711
850
 
712
851
  def inflate_obstacles(self, radius: float = 1.0) -> None:
713
852
  """
@@ -716,9 +855,8 @@ class Grid(BaseMap):
716
855
  Args:
717
856
  radius: Radius of the inflation.
718
857
  """
719
- self.update_esdf()
720
- mask = (self.esdf <= radius) & (self.type_map.data == TYPES.FREE)
721
- self.type_map[mask] = TYPES.INFLATION
858
+ mask = (self.esdf <= radius) & (self._type_map.data == TYPES.FREE)
859
+ self._type_map[mask] = TYPES.INFLATION
722
860
  self.inflation_radius = radius
723
861
 
724
862
  def fill_expands(self, expands: Dict[Tuple[int, ...], Node]) -> None:
@@ -729,9 +867,9 @@ class Grid(BaseMap):
729
867
  expands: List of expands.
730
868
  """
731
869
  for expand in expands.keys():
732
- if self.type_map[expand] != TYPES.FREE:
870
+ if self._type_map[expand] != TYPES.FREE:
733
871
  continue
734
- self.type_map[expand] = TYPES.EXPAND
872
+ self._type_map[expand] = TYPES.EXPAND
735
873
 
736
874
  def update_esdf(self) -> None:
737
875
  """
@@ -739,7 +877,7 @@ class Grid(BaseMap):
739
877
  - Obstacle grid ESDF = 0
740
878
  - Free grid ESDF > 0. The value is the di/stance to the nearest obstacle
741
879
  """
742
- obstacle_mask = (self.type_map.data == TYPES.OBSTACLE)
880
+ obstacle_mask = (self._type_map.data == TYPES.OBSTACLE)
743
881
  free_mask = ~obstacle_mask
744
882
 
745
883
  # distance to obstacles
@@ -749,6 +887,16 @@ class Grid(BaseMap):
749
887
 
750
888
  self._esdf = dist_outside.astype(np.float32)
751
889
  self._esdf[obstacle_mask] = -dist_inside[obstacle_mask]
890
+ self._esdf_lazy_flag_ = False
891
+
892
+ def update_connectivity(self) -> None:
893
+ """Update the free-space connected component map."""
894
+ type_map = self._type_map.data
895
+ free_mask = (type_map != TYPES.OBSTACLE) & (type_map != TYPES.INFLATION)
896
+ connectivity = 1 if self.strict_collision else self.dim
897
+ structure = ndimage.generate_binary_structure(self.dim, connectivity)
898
+ ndimage.label(free_mask, structure=structure, output=self._connectivity_map)
899
+ self._connectivity_lazy_flag_ = False
752
900
 
753
901
  def path_map_to_world(self, path: List[tuple]) -> List[Tuple[float, ...]]:
754
902
  """
@@ -807,8 +955,16 @@ class Grid(BaseMap):
807
955
  Returns:
808
956
  point: a point in integer coordinates
809
957
  """
810
- point_int = _grid_point_float_to_int(np.asarray(point, dtype=np.float64), self._shape_array)
811
- return tuple(int(x) for x in point_int)
958
+ shape = self.shape
959
+ point_int = []
960
+ for d in range(self.dim):
961
+ value = round(point[d])
962
+ if value < 0:
963
+ value = 0
964
+ elif value >= shape[d]:
965
+ value = shape[d] - 1
966
+ point_int.append(value)
967
+ return tuple(point_int)
812
968
 
813
969
  def _precompute_offsets(self):
814
970
  # Generate all possible offsets (-1, 0, +1) in each dimension
@@ -1,13 +1,14 @@
1
1
  """
2
2
  @file: geometry.py
3
3
  @author: Wu Maojia
4
- @update: 2025.10.3
4
+ @update: 2026.9.12
5
5
  """
6
6
  from typing import List, Tuple
7
7
  import math
8
8
 
9
9
  import numpy as np
10
10
 
11
+
11
12
  class Geometry:
12
13
  """
13
14
  Geometry tools class
@@ -29,7 +30,7 @@ class Geometry:
29
30
  if len(p1) != len(p2):
30
31
  raise ValueError("Dimension mismatch")
31
32
  if type == 'Euclidean':
32
- return math.sqrt(sum((a - b)** 2 for a, b in zip(p1, p2)))
33
+ return math.dist(p1, p2)
33
34
  elif type == 'Manhattan':
34
35
  return sum(abs(a - b) for a, b in zip(p1, p2))
35
36
  else:
@@ -1,7 +1,7 @@
1
1
  """
2
2
  @file: lazy_theta_star.py
3
3
  @author: Wu Maojia, Yang Haodong
4
- @update: 2025.10.6
4
+ @update: 2026.9.12
5
5
  """
6
6
  from typing import Union, List, Tuple, Dict, Any
7
7
  import heapq
@@ -61,12 +61,20 @@ class LazyThetaStar(ThetaStar):
61
61
  if node_p:
62
62
  if self.map_.in_collision(node_p.current, node.current):
63
63
  node.g = float("inf")
64
- for node_n in self.map_.get_neighbors(node, diagonal=self.diagonal):
65
- if node_n.current in CLOSED:
66
- node_n = CLOSED.get(node_n.current)
67
- if node.g > node_n.g + self.get_cost(node_n.current, node.current):
68
- node.g = node_n.g + self.get_cost(node_n.current, node.current)
69
- node.parent = node_n.current
64
+ get_neighbor_arrays = getattr(self.map_, "_get_neighbor_arrays", None)
65
+ if get_neighbor_arrays:
66
+ positions, mask = get_neighbor_arrays(node, diagonal=self.diagonal)
67
+ neighbors = (tuple(positions[i].tolist())
68
+ for i in range(positions.shape[0]) if mask[i])
69
+ else:
70
+ neighbors = (node_n.current for node_n in self.map_.get_neighbors(node, diagonal=self.diagonal))
71
+ for point in neighbors:
72
+ node_n = CLOSED.get(point)
73
+ if node_n:
74
+ cost = node_n.g + self.get_cost(point, node.current)
75
+ if node.g > cost:
76
+ node.g = cost
77
+ node.parent = point
70
78
 
71
79
  # exists in CLOSED list
72
80
  if node.current in CLOSED:
@@ -1,7 +1,7 @@
1
1
  """
2
2
  @file: theta_star.py
3
3
  @author: Wu Maojia, Yang Haodong
4
- @update: 2025.10.6
4
+ @update: 2026.9.12
5
5
  """
6
6
  from typing import Union, List, Tuple, Dict, Any
7
7
  import heapq
@@ -111,6 +111,7 @@ class ThetaStar(AStar):
111
111
  node_p (Node): parent node
112
112
  node_n (Node): next node
113
113
  """
114
- if node_p.g + self.get_cost(node_p.current, node_n.current) <= node_n.g:
115
- node_n.g = node_p.g + self.get_cost(node_p.current, node_n.current)
114
+ cost = node_p.g + self.get_cost(node_p.current, node_n.current)
115
+ if cost <= node_n.g:
116
+ node_n.g = cost
116
117
  node_n.parent = node_p.current
@@ -23,7 +23,7 @@ class RRT(BasePathPlanner):
23
23
  max_sample_step: Maximum number of steps of samples to generate.
24
24
  goal_sample_rate: Probability of sampling the goal directly.
25
25
  discrete: Whether to use discrete or continuous space.
26
- faiss: Whether to use Faiss to accelerate the search.
26
+ use_faiss: Whether to use Faiss to accelerate the search.
27
27
  *kwargs: see the parent class.
28
28
 
29
29
  References:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-motion-planning
3
- Version: 2.0.1
3
+ Version: 2.1
4
4
  Summary: Motion planning algorithms for Python
5
5
  Maintainer-email: Wu Maojia <omige@mail.nwpu.edu.cn>, Yang Haodong <913982779@qq.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -681,7 +681,7 @@ Project-URL: Repository, https://github.com/ai-winter/python_motion_planning
681
681
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
682
682
  Classifier: Programming Language :: Python :: 3
683
683
  Classifier: Programming Language :: Python :: 3 :: Only
684
- Requires-Python: >=3.6
684
+ Requires-Python: >=3.8
685
685
  Description-Content-Type: text/markdown
686
686
  License-File: LICENSE
687
687
  Requires-Dist: numpy
@@ -64,4 +64,6 @@ src/python_motion_planning/traj_optimizer/curve_generator/pose_based/__init__.py
64
64
  src/python_motion_planning/traj_optimizer/curve_generator/pose_based/bezier.py
65
65
  src/python_motion_planning/traj_optimizer/curve_generator/pose_based/dubins.py
66
66
  src/python_motion_planning/traj_optimizer/curve_generator/pose_based/polynomial.py
67
- src/python_motion_planning/traj_optimizer/curve_generator/pose_based/reeds_shepp.py
67
+ src/python_motion_planning/traj_optimizer/curve_generator/pose_based/reeds_shepp.py
68
+ tests/test_anya.py
69
+ tests/test_grid_line_symmetry.py
@@ -0,0 +1,67 @@
1
+ import math
2
+
3
+ from python_motion_planning.common import Grid, TYPES
4
+ from python_motion_planning.path_planner import Anya
5
+
6
+
7
+ def test_anya_empty_map_returns_straight_path():
8
+ map_ = Grid(bounds=[[0, 12], [0, 12]])
9
+ path, info = Anya(map_=map_, start=(1, 2), goal=(10, 9)).plan()
10
+
11
+ assert info["success"]
12
+ assert path == [(1, 2), (10, 9)]
13
+ assert math.isclose(info["length"], math.hypot(9, 7))
14
+
15
+
16
+ def test_anya_routes_around_obstacle():
17
+ map_ = Grid(bounds=[[0, 12], [0, 12]])
18
+ map_[5, 2:10] = TYPES.OBSTACLE
19
+
20
+ path, info = Anya(map_=map_, start=(2, 5), goal=(9, 5)).plan()
21
+
22
+ assert info["success"]
23
+ assert path[0] == (2, 5)
24
+ assert path[-1] == (9, 5)
25
+ assert len(path) > 2
26
+ assert all(isinstance(value, int) for point in path for value in point)
27
+ assert all(not map_.in_collision(path[i - 1], path[i]) for i in range(1, len(path)))
28
+
29
+
30
+ def test_anya_reports_unreachable_goal():
31
+ map_ = Grid(bounds=[[0, 10], [0, 10]])
32
+ map_[5, :] = TYPES.OBSTACLE
33
+
34
+ path, info = Anya(map_=map_, start=(2, 5), goal=(8, 5)).plan()
35
+
36
+ assert path == []
37
+ assert not info["success"]
38
+
39
+
40
+ def test_anya_respects_strict_collision():
41
+ map_ = Grid(bounds=[[0, 5], [0, 5]])
42
+ map_[1, 0] = TYPES.OBSTACLE
43
+
44
+ path, info = Anya(map_=map_, start=(0, 0), goal=(1, 1)).plan()
45
+ assert info["success"]
46
+ assert len(path) > 2
47
+ assert all(not map_.in_collision(path[i - 1], path[i]) for i in range(1, len(path)))
48
+
49
+ map_.strict_collision = False
50
+ path, info = Anya(map_=map_, start=(0, 0), goal=(1, 1)).plan()
51
+ assert info["success"]
52
+ assert path == [(0, 0), (1, 1)]
53
+
54
+
55
+ def test_anya_uses_grid_rule_for_double_corner():
56
+ map_ = Grid(bounds=[[0, 3], [0, 3]])
57
+ map_[1, 0] = TYPES.OBSTACLE
58
+ map_[0, 1] = TYPES.OBSTACLE
59
+
60
+ path, info = Anya(map_=map_, start=(0, 0), goal=(1, 1)).plan()
61
+ assert path == []
62
+ assert not info["success"]
63
+
64
+ map_.strict_collision = False
65
+ path, info = Anya(map_=map_, start=(0, 0), goal=(1, 1)).plan()
66
+ assert info["success"]
67
+ assert path == [(0, 0), (1, 1)]
@@ -0,0 +1,81 @@
1
+ import itertools
2
+ import unittest
3
+
4
+ import numpy as np
5
+
6
+ from python_motion_planning.common import Grid, TYPES
7
+
8
+
9
+ class GridLineSymmetryTest(unittest.TestCase):
10
+ def test_half_cell_ties(self):
11
+ for dim in (2, 3, 4):
12
+ grid = Grid(bounds=[[0, 13]] * dim)
13
+ start = (6,) * dim
14
+ for delta in itertools.product(range(-3, 4), repeat=dim):
15
+ end = tuple(a + b for a, b in zip(start, delta))
16
+ path = grid.line_of_sight(start, end)
17
+ self.assertEqual(path, grid.line_of_sight(end, start)[::-1])
18
+ self.assertEqual(path[0], start)
19
+ self.assertEqual(path[-1], end)
20
+ # Independent rational reference: ties follow the increasing
21
+ # primary-axis traversal, including when requested in reverse.
22
+ axis = max(range(dim), key=lambda d: abs(delta[d]))
23
+ a, b = (start, end) if delta[axis] >= 0 else (end, start)
24
+ steps = abs(delta[axis])
25
+ expected = [a]
26
+ for i in range(1, steps + 1):
27
+ expected.append(tuple(
28
+ a[d] + (1 if b[d] >= a[d] else -1)
29
+ * ((2 * i * abs(b[d] - a[d]) + steps - 1) // (2 * steps))
30
+ for d in range(dim)
31
+ ))
32
+ if delta[axis] < 0:
33
+ expected.reverse()
34
+ self.assertEqual(path, expected)
35
+
36
+ def test_inflated_corner_regression(self):
37
+ grid = Grid(bounds=[[0, 51], [0, 31]])
38
+ grid.fill_boundary_with_obstacles()
39
+ grid[10:21, 15] = TYPES.OBSTACLE
40
+ grid[20, :15] = TYPES.OBSTACLE
41
+ grid[30, 15:] = TYPES.OBSTACLE
42
+ grid[40, :16] = TYPES.OBSTACLE
43
+ grid.inflate_obstacles(radius=3)
44
+ a, b = (27, 12), (31, 11)
45
+ self.assertEqual(grid[30, 12], TYPES.INFLATION)
46
+ self.assertTrue(grid.in_collision(a, b))
47
+ self.assertTrue(grid.in_collision(b, a))
48
+ grid.strict_collision = False
49
+ self.assertFalse(grid.in_collision(a, b))
50
+ self.assertFalse(grid.in_collision(b, a))
51
+ grid.strict_collision = True
52
+ grid[30, 12] = TYPES.FREE
53
+ self.assertFalse(grid.in_collision(a, b))
54
+ self.assertFalse(grid.in_collision(b, a))
55
+
56
+ def test_random_collision_matches_steps(self):
57
+ rng = np.random.default_rng(0)
58
+ for dim in (2, 3, 4):
59
+ shape = (9,) * dim
60
+ data = np.zeros(shape, dtype=np.int8)
61
+ values = rng.random(shape)
62
+ data[values < 0.08] = TYPES.OBSTACLE
63
+ data[(values >= 0.08) & (values < 0.16)] = TYPES.INFLATION
64
+ grid = Grid(bounds=[[0, 9]] * dim, type_map=data)
65
+ for strict in (False, True):
66
+ grid.strict_collision = strict
67
+ for _ in range(500):
68
+ a = tuple(int(x) for x in rng.integers(0, 9, dim))
69
+ b = tuple(int(x) for x in rng.integers(0, 9, dim))
70
+ path = grid.line_of_sight(a, b)
71
+ expected = not grid.is_expandable(a) or any(
72
+ not grid.is_expandable(v, u)
73
+ for u, v in zip(path, path[1:])
74
+ )
75
+ actual = grid.in_collision(a, b)
76
+ self.assertEqual(actual, expected)
77
+ self.assertEqual(actual, grid.in_collision(b, a))
78
+
79
+
80
+ if __name__ == '__main__':
81
+ unittest.main()