Skip to content

Commit ae1941e

Browse files
committed
IDA* finder
1 parent 5f80dcb commit ae1941e

File tree

6 files changed

+192
-42
lines changed

6 files changed

+192
-42
lines changed

pathfinding/core/node.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,15 @@ def __init__(self, x=0, y=0, walkable=True):
2929
# used for backtracking to the start point
3030
self.parent = None
3131

32+
# used for recurion tracking of IDA*
33+
self.retain_count = 0
34+
self.tested = False
35+
3236
def __lt__(self, other):
3337
"""
3438
nodes are sorted by f value (see a_star.py)
3539
3640
:param other: compare Node
3741
:return:
3842
"""
39-
return self.f < other.f
43+
return self.f < other.f

pathfinding/finder/a_star.py

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
import math
33
import heapq # used for the so colled "open list" that stores known nodes
44
import logging
5+
import time # for time limitation
56
from pathfinding.core.heuristic import manhatten, octile
67
from pathfinding.core.util import backtrace, bi_backtrace
78
from pathfinding.core.diagonal_movement import DiagonalMovement
89

910

10-
# max. amount of tries until we abort the search
11+
# max. amount of tries we iterate until we abort the search
1112
MAX_RUNS = float('inf')
13+
# max. time after we until we abort the search (in seconds)
14+
TIME_LIMIT = float('inf')
1215

1316
# square root of 2
1417
SQRT2 = math.sqrt(2)
@@ -20,16 +23,25 @@
2023

2124
class AStarFinder(object):
2225
def __init__(self, heuristic=None, weight=1,
23-
diagonal_movement=DiagonalMovement.never):
26+
diagonal_movement=DiagonalMovement.never,
27+
time_limit=TIME_LIMIT,
28+
max_runs=MAX_RUNS):
2429
"""
2530
find shortest path using A* algorithm
2631
:param heuristic: heuristic used to calculate distance of 2 points
2732
(defaults to manhatten)
2833
:param weight: weight for the edges
2934
:param diagonal_movement: if diagonal movement is allowed
3035
(see enum in diagonal_movement)
31-
:return:
36+
:param time_limit: max. runtime in seconds
37+
:param max_runs: max. amount of tries until we abort the search
38+
(optional, only if we enter huge grids and have time constrains)
39+
<=0 means there are no constrains and the code might run on any
40+
large map.
3241
"""
42+
self.time_limit = time_limit
43+
self.max_runs = max_runs
44+
3345
self.diagonal_movement = diagonal_movement
3446
self.weight = weight
3547

@@ -60,8 +72,9 @@ def apply_heuristic(self, node_a, node_b):
6072
"""
6173
helper function to calculate heuristic
6274
"""
63-
return self.weight * \
64-
self.heuristic(abs(node_a.x - node_b.x), abs(node_a.y - node_b.y))
75+
return self.heuristic(
76+
abs(node_a.x - node_b.x),
77+
abs(node_a.y - node_b.y))
6578

6679

6780
def check_neighbors(self, start, end, grid, open_list,
@@ -100,7 +113,8 @@ def check_neighbors(self, start, end, grid, open_list,
100113
# can be reached with smaller cost from the current node
101114
if not neighbor.opened or ng < neighbor.g:
102115
neighbor.g = ng
103-
neighbor.h = neighbor.h or self.apply_heuristic(neighbor, end)
116+
neighbor.h = neighbor.h or \
117+
self.apply_heuristic(neighbor, end) * self.weight
104118
# f is the estimated total cost from start to goal
105119
neighbor.f = neighbor.g + neighbor.h
106120
neighbor.parent = node
@@ -118,35 +132,47 @@ def check_neighbors(self, start, end, grid, open_list,
118132
# the end has not been reached (yet) keep the find_path loop running
119133
return None
120134

121-
122-
def find_path(self, start, end, grid, max_runs=MAX_RUNS):
135+
def keep_running(self):
136+
"""
137+
check, if we run into time or iteration constrains.
138+
"""
139+
if self.runs >= self.max_runs:
140+
logging.error('{} run into barrier of {} iterations without '
141+
'finding the destination'.format(
142+
self.__name__, self.max_runs))
143+
return False
144+
if time.time() - self.start_time >= self.time_limit:
145+
logging.error('{} took longer than {} '
146+
'seconds, aborting!'.format(
147+
self.__name__, self.time_limit))
148+
return False
149+
return True
150+
151+
152+
def find_path(self, start, end, grid):
123153
"""
124154
find a path from start to end node on grid using the A* algorithm
125155
:param start: start node
126156
:param end: end node
127157
:param grid: grid that stores all possible steps/tiles as 2D-list
128-
:param max_runs: max. amount of tries until we abort the search
129-
(optional, only if we enter huge grids and have time constrains)
130-
<=0 means there are no constrains and the code might run on any
131-
large map.
132158
:return:
133159
"""
160+
self.start_time = time.time() # execution time limitation
161+
self.runs = 0 # count number of iterations
162+
134163
open_list = []
135164
start.g = 0
136165
start.f = 0
137166
heapq.heappush(open_list, start)
138167

139-
runs = 0 # count number of iterations
140168
while len(open_list) > 0:
141-
runs += 1
142-
if max_runs <= runs:
143-
logging.error('A* run into barrier of {} iterations without '
144-
'finding the destination'.format(max_runs))
169+
self.runs += 1
170+
if not self.keep_running():
145171
break
146172

147173
path = self.check_neighbors(start, end, grid, open_list)
148174
if path:
149-
return path, runs
175+
return path, self.runs
150176

151177
# failed to find path
152-
return [], runs
178+
return [], self.runs

pathfinding/finder/bi_a_star.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,48 +10,43 @@ class BiAStarFinder(AStarFinder):
1010
Similar to the default A* algorithm from a_star.
1111
"""
1212

13-
def find_path(self, start, end, grid, max_runs=MAX_RUNS):
13+
def find_path(self, start, end, grid):
1414
"""
1515
find a path from start to end node on grid using the A* algorithm
1616
:param start: start node
1717
:param end: end node
1818
:param grid: grid that stores all possible steps/tiles as 2D-list
19-
:param max_runs: max. amount of tries until we abort the search
20-
(optional, only if we enter huge grids and have time constrains)
21-
<=0 means there are no constrains and the code might run on any
22-
large map.
2319
:return:
2420
"""
21+
self.start_time = time.time() # execution time limitation
22+
self.runs = 0 # count number of iterations
23+
2524
start_open_list = []
26-
end_open_list = []
2725
start.g = 0
2826
start.f = 0
2927
heapq.heappush(start_open_list, start)
3028
start.opened = BY_START
3129

30+
end_open_list = []
3231
end.g = 0
3332
end.f = 0
3433
heapq.heappush(end_open_list, end)
3534
end.opened = BY_END
3635

37-
runs = 0 # count number of iterations
3836
while len(start_open_list) > 0 and len(end_open_list) > 0:
39-
runs += 1
40-
if 0 < max_runs <= runs:
41-
logging.error('Bi-Directional A* run into barrier of {} '
42-
'iterations without finding the '
43-
'destination'.format(max_runs))
37+
self.runs += 1
38+
if not self.keep_running():
4439
break
4540

4641
path = self.check_neighbors(start, end, grid, start_open_list,
4742
open_value=BY_START, backtrace_by=BY_END)
4843
if path:
49-
return path, runs
44+
return path, self.runs
5045

5146
path = self.check_neighbors(end, start, grid, end_open_list,
5247
open_value=BY_END, backtrace_by=BY_START)
5348
if path:
54-
return path, runs
49+
return path, self.runs
5550

5651
# failed to find path
57-
return [], runs
52+
return [], self.runs

pathfinding/finder/dijkstra.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1-
from .a_star import AStarFinder
1+
from .a_star import AStarFinder, MAX_RUNS, TIME_LIMIT
22
from pathfinding.core.diagonal_movement import DiagonalMovement
33

44

55
class DijkstraFinder(AStarFinder):
6-
def __init__(self, weight=1, diagonal_movement=DiagonalMovement.never):
6+
def __init__(self, weight=1,
7+
diagonal_movement=DiagonalMovement.never,
8+
time_limit=TIME_LIMIT,
9+
max_runs=MAX_RUNS):
10+
self.time_limit = time_limit
11+
self.max_runs = max_runs
12+
713
def heuristic(dx, dy):
814
# return 0, so node.h will always be calculated as 0,
9-
# distance cost (node.f) is calculated only from
15+
# distance cost (node.f) is calculated only from
1016
# start to current point (node.g)
1117
return 0
1218

1319
super(DijkstraFinder, self).__init__(
1420
weight=weight, diagonal_movement=diagonal_movement)
1521

16-
self.heuristic = heuristic
22+
self.heuristic = heuristic

pathfinding/finder/ida_star.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from .a_star import *
2+
from pathfinding.core.node import Node
3+
4+
class IDAStarFinder(AStarFinder):
5+
"""
6+
Iterative Deeping A Star (IDA*) path-finder.
7+
8+
Recursion based on:
9+
http://www.apl.jhu.edu/~hall/AI-Programming/IDA-Star.html
10+
11+
Path retracing based on:
12+
V. Nageshwara Rao, Vipin Kumar and K. Ramesh
13+
"A Parallel Implementation of Iterative-Deeping-A*", January 1987.
14+
ftp://ftp.cs.utexas.edu/.snapshot/hourly.1/pub/AI-Lab/tech-reports/UT-AI-TR-87-46.pdf
15+
16+
based on the JavaScript implementation by Gerard Meier (www.gerardmeier.com)
17+
"""
18+
def __init__(self, heuristic=None, weight=1,
19+
diagonal_movement=DiagonalMovement.never,
20+
time_limit=TIME_LIMIT,
21+
max_runs=MAX_RUNS,
22+
track_recursion=True):
23+
super(IDAStarFinder, self).__init__(heuristic=heuristic, weight=weight,
24+
diagonal_movement=diagonal_movement,
25+
time_limit=time_limit,
26+
max_runs=max_runs)
27+
self.track_recursion = track_recursion
28+
29+
def search(self, node, g, cutoff, path, depth, end, grid):
30+
self.nodes_visited += 1
31+
32+
if not self.keep_running():
33+
# time or iteration limit
34+
return
35+
36+
f = g + self.apply_heuristic(node, end) * self.weight
37+
38+
# We've searched too deep for this iteration.
39+
if f > cutoff:
40+
return f
41+
42+
if node == end:
43+
if len(path) < depth:
44+
path += [None] * (depth - len(path) + 1)
45+
path[depth] = node
46+
return node
47+
48+
neighbors = grid.neighbors(node, self.diagonal_movement)
49+
50+
# Sort the neighbors, gives nicer paths. But, this deviates
51+
# from the original algorithm - so I left it out
52+
# TODO: make this an optional parameter
53+
# def sort_neighbors(a, b):
54+
# return self.apply_heuristic(a, end) - \
55+
# self.apply_heuristic(b, end)
56+
# sorted(neighbors, sort_neighbors)
57+
min_t = float('inf')
58+
k = 0
59+
for neighbor in neighbors:
60+
if self.track_recursion:
61+
# Retain a copy for visualisation. Due to recursion, this
62+
# node may be part of other paths too.
63+
neighbor.retain_count += 1;
64+
neighbor.tested = True
65+
66+
t = self.search(neighbor, g + self.calc_cost(node, neighbor),
67+
cutoff, path, depth + 1, end, grid)
68+
69+
if isinstance(t, Node):
70+
if len(path) < depth:
71+
path += [None] * (depth - len(path) + 1)
72+
path[depth] = node
73+
return t
74+
75+
# Decrement count, then determine whether it's actually closed.
76+
if self.track_recursion:
77+
neighbor.retain_count -= 1
78+
if neighbor.retain_count == 0:
79+
neighbor.tested = False
80+
81+
if t < min_t:
82+
min_t = t
83+
84+
return min_t
85+
86+
87+
def find_path(self, start, end, grid):
88+
self.start_time = time.time() # execution time limitation
89+
self.runs = 0 # count number of iterations
90+
91+
self.nodes_visited = 0 # for statistics
92+
93+
self.runs = 0 # count number of iterations
94+
95+
# initial search depth, given the typical heuristic contraints,
96+
# there should be no cheaper route possible.
97+
cutoff = self.apply_heuristic(start, end)
98+
99+
while True:
100+
self.runs += 1
101+
102+
path = []
103+
104+
# search till cut-off depth:
105+
t = self.search(start, 0, cutoff, path, 0, end, grid)
106+
107+
# If t is a node, it's also the end node. Route is now
108+
# populated with a valid path to the end node.
109+
if isinstance(t, Node):
110+
return path, self.runs
111+
112+
# Try again, this time with a deeper cut-off. The t score
113+
# is the closest we got to the end node.
114+
cutoff = t
115+
116+
return [], self.runs

test/path_test.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathfinding.finder.a_star import AStarFinder
55
from pathfinding.finder.dijkstra import DijkstraFinder
66
from pathfinding.finder.bi_a_star import BiAStarFinder
7+
from pathfinding.finder.ida_star import IDAStarFinder
78
from pathfinding.core.grid import Grid
89
from pathfinding.core.diagonal_movement import DiagonalMovement
910

@@ -13,8 +14,9 @@
1314
# test scenarios from Pathfinding.JS
1415
scenarios = os.path.join(BASE_PATH, 'path_test_scenarios.json')
1516
data = json.load(open(scenarios, 'r'))
16-
finders = [AStarFinder, BiAStarFinder, DijkstraFinder]
17+
finders = [AStarFinder, BiAStarFinder, DijkstraFinder, IDAStarFinder]
1718

19+
TIME_LIMIT = 10 # give it a 10 second limit.
1820

1921
def test_path():
2022
"""
@@ -25,7 +27,7 @@ def test_path():
2527
grid = Grid(matrix=scenario['matrix'])
2628
start = grid.node(scenario['startX'], scenario['startY'])
2729
end = grid.node(scenario['endX'], scenario['endY'])
28-
finder = find()
30+
finder = find(time_limit=TIME_LIMIT)
2931
path, runs = finder.find_path(start, end, grid)
3032
print(find.__name__)
3133
print(grid.grid_str(path=path, start=start, end=end))
@@ -40,7 +42,8 @@ def test_path_diagonal():
4042
grid = Grid(matrix=scenario['matrix'])
4143
start = grid.node(scenario['startX'], scenario['startY'])
4244
end = grid.node(scenario['endX'], scenario['endY'])
43-
finder = find(diagonal_movement=DiagonalMovement.always)
45+
finder = find(diagonal_movement=DiagonalMovement.always,
46+
time_limit=TIME_LIMIT)
4447
path, runs = finder.find_path(start, end, grid)
4548
print(find.__name__, runs, len(path))
4649
print(grid.grid_str(path=path, start=start, end=end))

0 commit comments

Comments
 (0)