0

For a game i am working on, I have an implementation of the A* algorithm. I have a test that checks the route it generates for a given map, that passes in Python3.8/Windows but fails in Python3.5/Ubuntu. Why does the implementation give a different route in Python 38 than it does in Python 3.5?

Here is the function:

def a_star(start, goal, game_map,
           cannot_enter=['edge', 'water']):
    """A* finds a path from the units location to goal.

    start and goal are 2D coordinates
    game_map is the map object with terrain.
    cannot_enter is a list of impassible terrain

    from the wiki page: https://en.wikipedia.org/wiki/A*_search_algorithm"""

    # The set of discovered nodes that may need to be (re-)expanded.
    # Initially, only the start node is known.
    open_set = set([start])

    # For node n, cameFrom[n] is the node immediately preceding it on the cheapest path from start to n currently known.
    came_from = {}

    #For node n, g_score[n] is the cost of the cheapest path from start to n currently known.
    g_score = Map('G score', game_map.dims, 999999999, 9999999999)
    g_score[start] = 0

    #For node n, f_score[n] = g_score[n] + h(n).
    f_score = Map('F score', game_map.dims, 999999999, 9999999999)
    f_score[start] = ch_distance(start, goal)

    while len(open_set) > 0:
        current = min([kv for kv in f_score.items() if kv[0] in open_set], key=lambda i: i[1])[0]
        print("{} fscore: {} gscore: {}".format(current,
                                                f_score[current],
                                                g_score[current]))
        if current == goal:
            return reconstruct_path(came_from, current)

        open_set.remove(current)
        for neighbor in game_map.neighbors(current):
            #d(current, neighbor) is the weight of the edge from current to neighbor
            #tentative_g_score is the distance from start to the neighbor through current
            # print("{} is {} is it in {}?".format(neighbor,
            #                                      game_map[neighbor],
            #                                      cannot_enter))
            if game_map[neighbor] not in cannot_enter:
                tentative_g_score = g_score[current] + 1
            else:
                tentative_g_score = g_score[neighbor]
            if tentative_g_score < g_score[neighbor]:
                #This path to neighbor is better than any previous one. Record it!
                came_from[neighbor] = current
                g_score[neighbor] = tentative_g_score
                f_score[neighbor] = g_score[neighbor] + ch_distance(neighbor, goal)
                if neighbor not in open_set:
                    open_set.add(neighbor)
    #open_set is empty but goal was never reached
    return False

The game_map is a Map object, which is a subclass of dict that takes as keys 2D coordinates and the values are terrain whatever else you want to store about map squares. Map.neighbors((x, y)) returns a list of the coordinates of the neighbors of the given square.

I have a test of the route finder that passes on my Windows system with Python 3.8, but not on my Ubuntu system with Python 3.5 (IMO the Python versions are important, but I'm mentioning the OS as I could be persuaded otherwise).

def test_indirect_route(self):
    """
     01234567890
    0EEEEEEEEEEE
    1EsPPPPPPPPE
    2EP.PPPPPPPE
    3EPP.PPPPPPE
    4EPPP...PPPE
    5EWWWWWW.WWE
    6EPPPPP.PPPE
    7EPPPPPP.PPE
    8EPPPPPPP.PE
    9EPPPPPPPPgE
    0EEEEEEEEEEE
    Not the route I would have chosen, but the same length
    """
    test_map = Map()
    for x in range(1, 7):
        test_map[(x, 5)] = 'water'
    for x in range(8, 10):
        test_map[(x, 5)] = 'water'
    self.assertListEqual(a_star((1, 1), (9, 9), test_map),
                         [(1, 1), (2, 2), (3, 3), (4, 4), (5, 4), (6, 4), (7, 5), (6, 6), (7, 7), (8, 8), (9, 9)])

The test runner output is as follows. Note that the lengths of the routes are the same, just the specific route found varies between systems.

Failure
Traceback (most recent call last):
  File "/usr/lib/python3.5/unittest/case.py", line 58, in testPartExecutor
    yield
  File "/usr/lib/python3.5/unittest/case.py", line 600, in run
    testMethod()
  File "/home/rcriii/workspace/mPyre/src/test_a_star.py", line 47, in test_indirect_route
    [(1, 1), (2, 2), (3, 3), (4, 4), (5, 4), (6, 4), (7, 5), (6, 6), (7, 7), (8, 8), (9, 9)])
  File "/usr/lib/python3.5/unittest/case.py", line 1018, in assertListEqual
    self.assertSequenceEqual(list1, list2, msg, seq_type=list)
  File "/usr/lib/python3.5/unittest/case.py", line 1000, in assertSequenceEqual
    self.fail(msg)
  File "/usr/lib/python3.5/unittest/case.py", line 665, in fail
    raise self.failureException(msg)
AssertionError: Lists differ: [(1, [20 chars](4, 4), (5, 4), (6, 4), (7, 5), (7, 6), (8, 7), (9, 8), (9, 9)] != [(1, [20 chars](4, 4), (5, 4), (6, 4), (7, 5), (6, 6), (7, 7), (8, 8), (9, 9)]

First differing element 7:
(7, 6)
(6, 6)

  [(1, 1),
   (2, 2),
   (3, 3),
   (4, 4),
   (5, 4),
   (6, 4),
   (7, 5),
-  (7, 6),
?   ^

+  (6, 6),
?   ^

-  (8, 7),
?   ^

+  (7, 7),
?   ^

-  (9, 8),
?   ^

+  (8, 8),
?   ^

   (9, 9)]
rcriii
  • 687
  • 6
  • 9

1 Answers1

2

The problem lies in this line that is deciding which square to consider next for the route:

current = min([kv for kv in f_score.items() if kv[0] in open_set], key=lambda i: i[1])[0]
print("{} fscore: {} gscore: {}".format(current,
                                        f_score[current],
                                        g_score[current]))

f_score is a dictionary whose keys are coordinates and values are the f scores. Prior to Python 3.7, dict.__items was non-deterministic. So the output of the print statement looks as follows in Py3.5 vs Py3.8:

Python 3.5                    Python 3.8
-----------------------       -----------------------                 
(1, 1) fscore: 8 gscore: 0    (1, 1) fscore: 8 gscore: 0
(2, 2) fscore: 8 gscore: 1    (2, 2) fscore: 8 gscore: 1
(3, 3) fscore: 8 gscore: 2    (3, 3) fscore: 8 gscore: 2
(4, 4) fscore: 8 gscore: 3    (4, 4) fscore: 8 gscore: 3
(1, 2) fscore: 9 gscore: 1    (5, 5) fscore: 8 gscore: 4 <= Here they start to vary
(3, 2) fscore: 9 gscore: 2    (6, 6) fscore: 8 gscore: 5
(2, 1) fscore: 9 gscore: 1    (7, 7) fscore: 8 gscore: 6
(2, 3) fscore: 9 gscore: 2    (8, 8) fscore: 8 gscore: 7
(4, 3) fscore: 9 gscore: 3    (9, 9) fscore: 8 gscore: 8
(5, 4) fscore: 9 gscore: 4    (1, 1) fscore: 8 gscore: 0
(3, 4) fscore: 9 gscore: 3    (2, 2) fscore: 8 gscore: 1
(6, 4) fscore: 10 gscore: 5   (3, 3) fscore: 8 gscore: 2
(1, 3) fscore: 10 gscore: 2   (4, 4) fscore: 8 gscore: 3
(7, 5) fscore: 10 gscore: 6   (1, 2) fscore: 9 gscore: 1
(6, 6) fscore: 10 gscore: 7   (2, 1) fscore: 9 gscore: 1
(7, 7) fscore: 10 gscore: 8   (2, 3) fscore: 9 gscore: 2
(4, 2) fscore: 10 gscore: 3   (3, 2) fscore: 9 gscore: 2
(5, 3) fscore: 10 gscore: 4   (3, 4) fscore: 9 gscore: 3
(7, 6) fscore: 10 gscore: 7   (4, 3) fscore: 9 gscore: 3
(8, 7) fscore: 10 gscore: 8   (5, 4) fscore: 9 gscore: 4
(9, 8) fscore: 10 gscore: 9   (1, 3) fscore: 10 gscore: 2
(3, 1) fscore: 10 gscore: 2   (3, 1) fscore: 10 gscore: 2
(8, 8) fscore: 10 gscore: 9   (2, 4) fscore: 10 gscore: 3
(8, 6) fscore: 10 gscore: 7   (4, 2) fscore: 10 gscore: 3
(9, 7) fscore: 10 gscore: 8   (5, 3) fscore: 10 gscore: 4
(9, 9) fscore: 10 gscore: 10  (6, 4) fscore: 10 gscore: 5
 Here Python3.5 is done       (7, 5) fscore: 10 gscore: 6
 in 26 steps                  ... Not done yet! ...
                              Python3.8 keeps going
                              to 36 steps total

So not only do they generate a different routes, but the Python3.8 version takes 50% more steps to get to the route.

Note that to have passing tests, I should test that the route goes through the start, end, single bridge over the water, and that the length is 11 squares. This passes in both systems:

def test_indirect_route(self):
    """
     01234567890
    0EEEEEEEEEEE
    1EsPPPPPPPPE
    2EP.PPPPPPPE
    3EPP.PPPPPPE
    4EPPP...PPPE
    5EWWWWWW.WWE
    6EPPPPP.PPPE
    7EPPPPPP.PPE
    8EPPPPPPP.PE
    9EPPPPPPPPgE
    0EEEEEEEEEEE
    Not the route I would have chosen, but the same length
    """
    test_map = Map()
    for x in range(1, 7):
        test_map[(x, 5)] = 'water'
    for x in range(8, 10):
        test_map[(x, 5)] = 'water'
    route = a_star((1, 1), (9, 9), test_map)
    self.assertIn((1, 1), route)
    self.assertIn((7, 5), route)
    self.assertIn((9, 9), route)
    self.assertEqual(11, len(route))
rcriii
  • 687
  • 6
  • 9