scgraph.grid

  1from typing import Literal
  2from scgraph import Graph
  3from math import ceil
  4
  5
  6class GridGraph:
  7    def __init__(
  8        self,
  9        x_size: int,
 10        y_size: int,
 11        blocks: list[tuple[int, int]],
 12        shape: list[tuple[int | float, int | float]] | None = None,
 13        add_exterior_walls: bool = True,
 14        conn_data: list[tuple[int, int, float]] | None = None,
 15    ) -> None:
 16        """
 17        Initializes a GridGraph object.
 18
 19        Required Arguments:
 20
 21        - `x_size`
 22            - Type: int
 23            - What: The size of the grid in the x direction
 24        - `y_size`
 25            - Type: int
 26            - What: The size of the grid in the y direction
 27        - `blocks`
 28            - Type: list of tuples
 29            - What: A list of tuples representing the coordinates of the blocked cells
 30
 31        Optional Arguments:
 32
 33        - `shape`
 34            - Type: list of tuples
 35            - What: A list of tuples representing the shape of the moving object relative to the object location in the grid
 36            - Default: [(0, 0), (0, 1), (1, 0), (1, 1)]
 37            - Example: [(0, 0), (0, 1), (1, 0), (1, 1)]
 38                - Note: This would form a square of size 1x1
 39                - Assuming the grid location is at (0,0) the square takes up the grid space between (0, 0) and (1, 1)
 40                - Assumint the grid location is at (1,1) the square takes up the grid space between (1, 1) and (2, 2) with the same shape
 41            - Note: This shape is used to determine which connections are allowed when passing through the grid.
 42                - For example
 43                    - The shape is a square of size 1x1
 44                    - The location of the square is (0, 0)
 45                    - There is a blocked cell at (0, 1)
 46                    - There is a potential connection to (1, 1), (0, 1), and (1, 0)
 47                    - The square can not move to (1, 1) as it would collide with the blocked cell at (0, 1)
 48                      - This is because the square would pass partly through the blocked cell on its way to (1, 1)
 49                      - To achieve the same result, it can move to (1, 0) and then up to (1, 1) taking extra time / distance
 50        - `add_exterior_walls`
 51            - Type: bool
 52            - What: Whether to add exterior walls to the grid
 53            - Default: True
 54        - `conn_data`
 55            - Type: list of tuples
 56            - What: A list of tuples representing movements allowed in the grid
 57                - Each tuple should contain three values: (x_offset, y_offset, distance)
 58                - x_offset: The x offset from the current cell
 59                - y_offset: The y offset from the current cell
 60                - distance: The distance to the connected cell
 61            - Default: [(0, 1, 1), (1, 0, 1), (0, -1, 1), (-1, 0, 1), (1, 1, sqrt(2)), (-1, -1, sqrt(2)), (1, -1, sqrt(2)), (-1, 1, sqrt(2))]
 62                - Note: If None, the default connection data will be used
 63                - Note: This includes 8 directions, 4 cardinal and 4 diagonal
 64                - Cardinal directions are 1 unit away, diagonal directions are sqrt(2) units away
 65        """
 66        # Validate the inputs
 67        assert x_size > 0, "x_size must be greater than 0"
 68        assert y_size > 0, "y_size must be greater than 0"
 69
 70        self.x_size = x_size
 71        self.y_size = y_size
 72        self.blocks = blocks
 73        self.add_exterior_walls = add_exterior_walls
 74
 75        # Update the blocks if add_exterior_walls is True
 76        # This is done by adding all the cells in the first and last row and column
 77        if add_exterior_walls:
 78            self.blocks += [
 79                (x, y) for x in range(x_size) for y in [0, y_size - 1]
 80            ] + [(x, y) for x in [0, x_size - 1] for y in range(y_size)]
 81
 82        # If shape is None, set the default shape
 83        if shape is None:
 84            self.shape = [(0, 0), (0, 1), (1, 0), (1, 1)]
 85        else:
 86            self.shape = shape
 87
 88        # If conn_data is None, set the default connection data
 89        if conn_data is None:
 90            sqrt_2 = 1.4142
 91            self.conn_data = [
 92                (-1, -1, sqrt_2),
 93                (-1, 0, 1),
 94                (-1, 1, sqrt_2),
 95                (0, -1, 1),
 96                (0, 1, 1),
 97                (1, -1, sqrt_2),
 98                (1, 0, 1),
 99                (1, 1, sqrt_2),
100            ]
101        else:
102            self.conn_data = conn_data
103
104        self.graph = self.__create_graph__()
105        self.nodes = [self.__get_x_y__(idx) for idx in range(len(self.graph))]
106        self.graph_object = Graph(self.graph)
107
108    def __get_idx__(self, x: int, y: int):
109        """
110        Function:
111        - Get the index of a cell in a 2D grid given its x and y coordinates
112        - This does not have any error checking, so be careful with the inputs
113
114        Required Arguments:
115        - `x`
116            - Type: int
117            - What: The x coordinate of the cell
118        - `y`
119            - Type: int
120            - What: The y coordinate of the cell
121
122        Returns:
123
124        - `idx`
125            - Type: int
126            - What: The index of the cell in the grid
127        """
128        return x + y * self.x_size
129
130    def __get_x_y__(self, idx: int):
131        """
132        Function:
133        - Get the x and y coordinates of a cell in a 2D grid given its index
134        - This does not have any error checking so be careful with the inputs
135
136        Required Arguments:
137
138        - `idx`
139            - Type: int
140            - What: The index of the cell
141
142        Returns:
143        - `x`
144            - Type: int
145            - What: The x coordinate of the cell
146        - `y`
147            - Type: int
148            - What: The y coordinate of the cell
149        """
150        return idx % self.x_size, idx // self.x_size
151
152    def get_idx(self, x: int, y: int):
153        """
154        Function:
155        - Get the index of a cell in a 2D grid given its x and y coordinates
156
157        Required Arguments:
158        - `x`
159            - Type: int
160            - What: The x coordinate of the cell
161        - `y`
162            - Type: int
163            - What: The y coordinate of the cell
164
165        Returns:
166
167        - `idx`
168            - Type: int
169            - What: The index of the cell in the grid
170        """
171        assert x >= 0 and x < self.x_size, "x coordinate is out of bounds"
172        assert y >= 0 and y < self.y_size, "y coordinate is out of bounds"
173        return x + y * self.x_size
174
175    def get_x_y(self, idx: int):
176        """
177        Function:
178        - Get the x and y coordinates of a cell in a 2D grid given its index
179
180        Required Arguments:
181
182        - `idx`
183            - Type: int
184            - What: The index of the cell
185
186        Returns:
187        - `x`
188            - Type: int
189            - What: The x coordinate of the cell
190        - `y`
191            - Type: int
192            - What: The y coordinate of the cell
193        """
194        assert idx >= 0 and idx < len(self.graph), "idx is out of bounds"
195        return idx % self.x_size, idx // self.x_size
196
197    def __get_relative_shape_offsets__(
198        self,
199        shape: list[tuple[int | float, int | float]],
200        x_off: int,
201        y_off: int,
202    ):
203        """
204        Returns the list of (x, y) integer grid cells that the shape overlaps
205        as it moves from (0, 0) to (x_off, y_off).
206        """
207        # Get bounding box of the shape at start and end positions
208        xs = [coord[0] for coord in shape]
209        ys = [coord[1] for coord in shape]
210
211        # Calculate the 1d overlaps in x and y directions
212        x_cells = list(
213            range(
214                int(min(xs) + min(0, x_off)), int(ceil(max(xs) + max(0, x_off)))
215            )
216        )
217        y_cells = list(
218            range(
219                int(min(ys) + min(0, y_off)), int(ceil(max(ys) + max(0, y_off)))
220            )
221        )
222
223        # Combine to get 2D cell overlaps
224        result = set()
225        for x_key in x_cells:
226            for y_key in y_cells:
227                result.add((x_key, y_key))
228
229        # Remove untouched cells if moving diagonally
230        if x_off != 0 and y_off != 0:
231            slope = y_off / x_off
232
233            # Find min and max vertex given the slope
234            orthogonal = -1 / slope  # Compute orthogonal slope
235            # Define direction projections (a normalized direction vector, but without the linear algebra)
236            # Note: Technically normalized length is (1**2 + orthogonal**2)**.5, but we avoid the extra square for performance
237            length = (1 + orthogonal**2) ** 0.5
238            projections = [
239                x * 1.0 / length + y * orthogonal / length for x, y in shape
240            ]
241            # Return the min and max verticies
242            min_vertex = shape[
243                min(enumerate(projections), key=lambda x: x[1])[0]
244            ]
245            max_vertex = shape[
246                max(enumerate(projections), key=lambda x: x[1])[0]
247            ]
248            if slope > 0:
249                min_vertex, max_vertex = max_vertex, min_vertex
250
251            shape_min_intercept = min_vertex[1] - slope * min_vertex[0]
252            shape_max_intercept = max_vertex[1] - slope * max_vertex[0]
253
254            ltx_increment, gtx_increment = (1, 0) if slope < 0 else (0, 1)
255
256            remove_cells = []
257            for x_cell, y_cell in result:
258                cell_min_intercept = y_cell - (slope * (x_cell + gtx_increment))
259                cell_max_intercept = (y_cell + 1) - (
260                    slope * (x_cell + ltx_increment)
261                )
262
263                if not (
264                    cell_min_intercept < shape_max_intercept
265                    and shape_min_intercept < cell_max_intercept
266                ):
267                    remove_cells.append((x_cell, y_cell))
268            for key in remove_cells:
269                result.remove(key)
270        # Return the list of offsets
271        return list(result)
272
273    def __create_graph__(
274        self,
275    ):
276        """
277        Function:
278
279        - Create a graph from the grid specifications
280
281        Returns:
282
283        - `graph`
284            - Type: list
285            - What: The adjacency list representation of the graph
286        """
287        ####################
288        # Create a list of lists to hold all the possible connections in an easy to access/understand format
289        ####################
290
291        # Each cell in the list will be a dictionary of connections
292        # The keys will be the coordinates of the cell that can be moved to
293        # The values will be the distance to that cell
294        # This essentially creates a sparse matrix
295        graph_matrix = [
296            [{} for _ in range(self.x_size)] for _ in range(self.y_size)
297        ]
298        for y in range(self.y_size):
299            for x in range(self.x_size):
300                for x_off, y_off, dist in self.conn_data:
301                    if (
302                        0 <= x + x_off < self.x_size
303                        and 0 <= y + y_off < self.y_size
304                    ):
305                        graph_matrix[y][x][(x + x_off, y + y_off)] = dist
306
307        ####################
308        # Remove all connections that would not be possible based on the blocks and the shape
309        ####################
310
311        # Create a set of offsets that will be used to help remove connections relative to each blocked cell
312        delta_offsets = {
313            (x_delta, y_delta): self.__get_relative_shape_offsets__(
314                self.shape, x_delta, y_delta
315            )
316            for x_delta, y_delta, _ in self.conn_data
317        }
318
319        # Loop over all the blocks and remove all connections that would be not possible based on the blocks
320        for block in self.blocks:
321            # Clear out all connections leaving from the blocked cell
322            graph_matrix[block[1]][block[0]] = {}
323            # Clear out all connections that would be blocked by each movement given the blocked cell and the delta offsets
324            # Our delta offsets dictionary essentially tells us which relative cells would be blocked by the moving shape
325            for delta, offsets in delta_offsets.items():
326                for offset in offsets:
327                    x_cell = block[0] - offset[0]
328                    y_cell = block[1] - offset[1]
329                    if 0 <= x_cell < self.x_size and 0 <= y_cell < self.y_size:
330                        x_move_to = x_cell + delta[0]
331                        y_move_to = y_cell + delta[1]
332                        graph_matrix[y_cell][x_cell].pop(
333                            (x_move_to, y_move_to), None
334                        )
335
336        ####################
337        # Convert the graph matrix into the very efficient graph format as specified by the scgraph library
338        ####################
339        graph = []
340        for x_data in graph_matrix:
341            for connection_dict in x_data:
342                graph.append(
343                    {
344                        self.__get_idx__(x, y): dist
345                        for (x, y), dist in connection_dict.items()
346                    }
347                )
348
349        return graph
350
351    def euclidean_heuristic(self, origin_id: int, destination_id: int) -> float:
352        """
353        Function:
354
355        - Calculate the Euclidean distance between two nodes in the grid graph
356
357        Required Arguments:
358
359        - `origin_id`
360            - Type: int
361            - What: The id of the origin node
362        - `destination_id`
363            - Type: int
364            - What: The id of the destination node
365
366        Returns:
367
368        - `distance`
369            - Type: float
370            - What: The Euclidean distance between the two nodes
371        """
372        origin_location = self.nodes[origin_id]
373        destination_location = self.nodes[destination_id]
374        return (
375            (origin_location[0] - destination_location[0]) ** 2
376            + (origin_location[1] - destination_location[1]) ** 2
377        ) ** 0.5
378
379    def __get_closest_node_with_connections__(
380        self,
381        x: int | float,
382        y: int | float,
383    ):
384        """
385        Function:
386
387        - Get the closest node in the graph that has connections
388
389        Required Arguments:
390
391        - `x`
392            - Type: int | float
393            - What: The x coordinate of the node
394        - `y`
395            - Type: int | float
396            - What: The y coordinate of the node
397
398        Returns:
399
400        - `closest_node_id`
401            - Type: int
402            - What: The id of the closest node with connections
403        - `closest_distance`
404            - Type: float
405            - What: The distance to the closest node with connections
406        """
407        x = int(x) if int(x) == x else x
408        y = int(y) if int(y) == y else y
409        if isinstance(x, int) and isinstance(y, int):
410            return self.get_idx(x, y), 0.0
411        closest_node_id = None
412        closest_distance = float("inf")
413        for x_off, y_off in [(0, 0), (1, 0), (0, 1), (1, 1)]:
414            node_x = int(x) + x_off
415            node_y = int(y) + y_off
416            try:
417                node_id = self.get_idx(node_x, node_y)
418            except AssertionError:
419                continue
420            if self.graph[node_id] == {}:
421                continue
422            distance = ((node_x - x) ** 2 + (node_y - y) ** 2) ** 0.5
423            if distance < closest_distance:
424                closest_distance = distance
425                closest_node_id = node_id
426        if closest_node_id is None:
427            raise ValueError(
428                "No valid adjacent node with connections found for the given coordinates"
429            )
430        return closest_node_id, closest_distance
431
432    def format_coordinate(self, cooinate_dict, output_coorinate_path):
433        if output_coorinate_path == "list_of_tuples":
434            return (cooinate_dict["x"], cooinate_dict["y"])
435        elif output_coorinate_path == "list_of_lists":
436            return [cooinate_dict["x"], cooinate_dict["y"]]
437        elif output_coordinate_path == "list_of_dicts":
438            return {"x": cooinate_dict["x"], "y": cooinate_dict["y"]}
439        else:
440            raise ValueError("Invalid output_coordinate_path format")
441
442    def get_shortest_path(
443        self,
444        origin_node: (
445            dict[Literal["x", "y"], int | float]
446            | tuple[int | float, int | float]
447            | list[int | float]
448        ),
449        destination_node: (
450            dict[Literal["x", "y"], int | float]
451            | tuple[int | float, int | float]
452            | list[int | float]
453        ),
454        output_coordinate_path: str = "list_of_dicts",
455        output_path: bool = False,
456        algorithm_fn: str = "dijkstra",
457        algorithm_kwargs: dict | None = None,
458        **kwargs,
459    ) -> dict:
460        """
461        Function:
462
463        - Identify the shortest path between two nodes in a sparse network graph
464        - If an off graph origin and/or destination node is provided, it will find the closest adjacent node with connections
465          as the origin and/or destination node
466            - This is done to increase the likelihood that the pathfinding algorithm can find a valid path
467            - If no valid adjacent node is found, an error will be raised
468
469        - Return a dictionary of various path information including:
470            - `path`: A list of graph ids in the order they are visited
471            - `coordinate_path`: A list of dicts (x, y) in the order they are visited
472            - `length`: The length of the path
473
474         Required Arguments:
475
476        - `origin_node`
477            - Type: dict of int | float
478            - What: A dictionary with the keys 'x' and 'y'
479            - Alternatively, a tuple or list of two values (x, y) can be used
480        - `destination_node`
481            - Type: dict of int | float
482            - What: A dictionary with the keys 'x' and 'y'
483            - Alternatively, a tuple or list of two values (x, y) can be used
484
485        Optional Arguments:
486
487        - `output_coordinate_path`
488            - Type: str
489            - What: The format of the output coordinate path
490            - Default: 'list_of_lists'
491            - Options:
492                - `list_of_tuples`: A list of tuples with the first value being x and the second being y
493                - 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
494                - 'list_of_lists': A list of lists with the first value being x and the second being y
495        - `output_path`
496            - Type: bool
497            - What: Whether to output the path as a list of graph idxs (mostly for debugging purposes)
498            - Default: False
499        - `algorithm_fn`
500            - Type: str | callable
501            - What: The algorithm to use for pathfinding
502            - Default: 'dijkstra'
503            - Options:
504                - 'dijkstra': Standard Dijkstra's algorithm (default)
505                - Any method name from the Graph class (e.g. 'bellman_ford', 'a_star', 'bmssp', 'cached_shortest_path', etc.)
506                - Any user defined function that takes origin_id, destination_id, and **algorithm_kwargs
507        - `algorithm_kwargs`
508            - Type: dict
509            - What: Additional keyword arguments to pass to the algorithm function
510            - Default: {}
511        - `**kwargs`
512            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
513        """
514        if isinstance(origin_node, (tuple, list)):
515            origin_node = {"x": origin_node[0], "y": origin_node[1]}
516        if isinstance(destination_node, (tuple, list)):
517            destination_node = {
518                "x": destination_node[0],
519                "y": destination_node[1],
520            }
521        if algorithm_kwargs is None:
522            algorithm_kwargs = {}
523        if callable(algorithm_fn):
524            algorithm_kwargs["graph"] = self.graph
525        elif isinstance(algorithm_fn, str) and hasattr(
526            self.graph_object, algorithm_fn
527        ):
528            algorithm_fn = getattr(self.graph_object, algorithm_fn)
529        else:
530            raise ValueError("algorithm_fn must be a string or callable")
531
532        origin_id, origin_distance = self.__get_closest_node_with_connections__(
533            **origin_node
534        )
535        destination_id, destination_distance = (
536            self.__get_closest_node_with_connections__(**destination_node)
537        )
538
539        if self.graph[origin_id] == {}:
540            raise ValueError(
541                "Origin node is not connected to any other nodes. This is likely caused by the origin node not being possible given a blocked cell or nearby blocked cell"
542            )
543        if self.graph[destination_id] == {}:
544            raise ValueError(
545                "Destination node is not connected to any other nodes. This is likely caused by the destination node not being possible given a blocked cell or nearby blocked cell"
546            )
547        output = algorithm_fn(
548            origin_id=origin_id,
549            destination_id=destination_id,
550            **algorithm_kwargs,
551        )
552        output["coordinate_path"] = self.__get_coordinate_path__(
553            output["path"], output_coordinate_path
554        )
555        if origin_distance > 0:
556            output["coordinate_path"] = [
557                self.format_coordinate(origin_node, output_coordinate_path)
558            ] + output["coordinate_path"]
559            output["path"] = [-1] + output["path"]
560            output["length"] += origin_distance
561        if destination_distance > 0:
562            output["coordinate_path"].append(
563                self.format_coordinate(destination_node, output_coordinate_path)
564            )
565            output["path"].append(-1)
566            output["length"] += destination_distance
567        if not output_path:
568            del output["path"]
569        return output
570
571    def __get_coordinate_path__(
572        self, path: list[int], output_coordinate_path: str = "list_of_dicts"
573    ) -> list[dict[str, int]]:
574        """
575        Function:
576
577        - Return a list of node dictionaries (lat + long) in the order they are visited
578
579        Required Arguments:
580
581        - `path`
582            - Type: list
583            - What: A list of node ids in the order they are visited
584
585        Optional Arguments:
586
587        - `output_coordinate_path`
588            - Type: str
589            - What: The format of the output coordinate path
590            - Default: 'list_of_dicts'
591            - Options:
592                - 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
593                - 'list_of_lists': A list of lists with the first value being x and the second being y
594                - `list_of_tuples`: A list of tuples with the first value being x and the second being y
595
596        Returns:
597
598        - `coordinate_path`
599            - Type: list
600            - What: A list of dictionaries with keys 'x' and 'y' in the order they are visited
601        """
602        output = [self.__get_x_y__(idx) for idx in path]
603        if output_coordinate_path == "list_of_tuples":
604            return output
605        elif output_coordinate_path == "list_of_lists":
606            return [[x, y] for x, y in output]
607        elif output_coordinate_path == "list_of_dicts":
608            return [{"x": x, "y": y} for x, y in output]
609        else:
610            raise ValueError(
611                "output_coordinate_path must be 'list_of_dicts' or 'list_of_lists' or 'list_of_tuples'"
612            )
613
614    def export_object(
615        self, filename: str = "", include_blocks: bool = False
616    ) -> dict:
617        """
618        Function:
619
620        - Export the graph as a list of dictionaries
621
622        Arguments:
623
624        - `filename`
625            - Type: str
626            - What: The name of the file to export the graph to.
627            - An extension of .gridgraph will be added to the file name if not already present
628
629        Optional Arguments:
630
631        - `include_blocks`
632            - Type: bool
633            - What: Whether to include the blocks in the export
634            - Default: False
635            - Note: This is not needed as the graph is already created
636            - Note: You can include blocks in the export if you need them for some reason
637                - This will be set as the blocks attribute in the imported object
638                - This will increase the size of the export file
639        """
640        if filename == "":
641            raise ValueError("filename must be specified to export the graph")
642        filename = (
643            filename
644            if filename.endswith(".gridgraph")
645            else filename + ".gridgraph"
646        )
647        try:
648            import pickle, zlib, os
649        except ImportError:
650            raise ImportError(
651                "os, pickle and zlib are required to export the graph"
652            )
653        export_data = {
654            "graph_attributes": {
655                "graph": self.graph,
656                "nodes": self.nodes,
657                "x_size": self.x_size,
658                "y_size": self.y_size,
659                "shape": self.shape,
660                "add_exterior_walls": self.add_exterior_walls,
661                "conn_data": self.conn_data,
662            },
663            "graph_cache": self.graph_object.get_cache(),
664            "export_version": 1,
665        }
666        if include_blocks:
667            export_data["graph_attributes"]["blocks"] = self.blocks
668
669        if not os.path.exists(os.path.dirname(filename)):
670            os.makedirs(os.path.dirname(filename))
671        with open(filename, "wb") as f:
672            f.write(zlib.compress(pickle.dumps(export_data)))
673
674    @staticmethod
675    def import_object(filename: str = "") -> None:
676        """
677        Function:
678
679        - A staticmethod to import the graph from a file
680
681        Arguments:
682
683        - `filename`
684            - Type: str
685            - What: The name of the file to import the graph from.
686            - An extension of .gridgraph will be added to the file name if not already present
687
688        Returns:
689
690        - `GridGraph`
691            - Type: GridGraph
692            - What: The imported graph object
693        """
694        if filename == "":
695            raise ValueError("filename must be specified to import the graph")
696        filename = (
697            filename
698            if filename.endswith(".gridgraph")
699            else filename + ".gridgraph"
700        )
701        try:
702            import pickle, zlib
703        except ImportError:
704            raise ImportError(
705                "pickle and zlib are required to import the graph"
706            )
707        with open(filename, "rb") as f:
708            import_data = pickle.loads(zlib.decompress(f.read()))
709
710        # Check the version of the export
711        if import_data["export_version"] != 1:
712            raise ValueError(
713                f"Incompatible export version {import_data['export_version']}. The current version is 1"
714            )
715        # Create a new basic GridGraph object and overwrite the attributes with the imported data
716        # This is done as the init method calls the __create_graph__ method which is not needed here
717        GridGraph_object = GridGraph(
718            x_size=1, y_size=1, blocks=[], add_exterior_walls=False
719        )
720        for key, value in import_data["graph_attributes"].items():
721            GridGraph_object.__setattr__(key, value)
722
723        GridGraph_object.graph_object = Graph(GridGraph_object.graph)
724        GridGraph_object.graph_object.set_cache(import_data["graph_cache"])
725        return GridGraph_object
class GridGraph:
  7class GridGraph:
  8    def __init__(
  9        self,
 10        x_size: int,
 11        y_size: int,
 12        blocks: list[tuple[int, int]],
 13        shape: list[tuple[int | float, int | float]] | None = None,
 14        add_exterior_walls: bool = True,
 15        conn_data: list[tuple[int, int, float]] | None = None,
 16    ) -> None:
 17        """
 18        Initializes a GridGraph object.
 19
 20        Required Arguments:
 21
 22        - `x_size`
 23            - Type: int
 24            - What: The size of the grid in the x direction
 25        - `y_size`
 26            - Type: int
 27            - What: The size of the grid in the y direction
 28        - `blocks`
 29            - Type: list of tuples
 30            - What: A list of tuples representing the coordinates of the blocked cells
 31
 32        Optional Arguments:
 33
 34        - `shape`
 35            - Type: list of tuples
 36            - What: A list of tuples representing the shape of the moving object relative to the object location in the grid
 37            - Default: [(0, 0), (0, 1), (1, 0), (1, 1)]
 38            - Example: [(0, 0), (0, 1), (1, 0), (1, 1)]
 39                - Note: This would form a square of size 1x1
 40                - Assuming the grid location is at (0,0) the square takes up the grid space between (0, 0) and (1, 1)
 41                - Assumint the grid location is at (1,1) the square takes up the grid space between (1, 1) and (2, 2) with the same shape
 42            - Note: This shape is used to determine which connections are allowed when passing through the grid.
 43                - For example
 44                    - The shape is a square of size 1x1
 45                    - The location of the square is (0, 0)
 46                    - There is a blocked cell at (0, 1)
 47                    - There is a potential connection to (1, 1), (0, 1), and (1, 0)
 48                    - The square can not move to (1, 1) as it would collide with the blocked cell at (0, 1)
 49                      - This is because the square would pass partly through the blocked cell on its way to (1, 1)
 50                      - To achieve the same result, it can move to (1, 0) and then up to (1, 1) taking extra time / distance
 51        - `add_exterior_walls`
 52            - Type: bool
 53            - What: Whether to add exterior walls to the grid
 54            - Default: True
 55        - `conn_data`
 56            - Type: list of tuples
 57            - What: A list of tuples representing movements allowed in the grid
 58                - Each tuple should contain three values: (x_offset, y_offset, distance)
 59                - x_offset: The x offset from the current cell
 60                - y_offset: The y offset from the current cell
 61                - distance: The distance to the connected cell
 62            - Default: [(0, 1, 1), (1, 0, 1), (0, -1, 1), (-1, 0, 1), (1, 1, sqrt(2)), (-1, -1, sqrt(2)), (1, -1, sqrt(2)), (-1, 1, sqrt(2))]
 63                - Note: If None, the default connection data will be used
 64                - Note: This includes 8 directions, 4 cardinal and 4 diagonal
 65                - Cardinal directions are 1 unit away, diagonal directions are sqrt(2) units away
 66        """
 67        # Validate the inputs
 68        assert x_size > 0, "x_size must be greater than 0"
 69        assert y_size > 0, "y_size must be greater than 0"
 70
 71        self.x_size = x_size
 72        self.y_size = y_size
 73        self.blocks = blocks
 74        self.add_exterior_walls = add_exterior_walls
 75
 76        # Update the blocks if add_exterior_walls is True
 77        # This is done by adding all the cells in the first and last row and column
 78        if add_exterior_walls:
 79            self.blocks += [
 80                (x, y) for x in range(x_size) for y in [0, y_size - 1]
 81            ] + [(x, y) for x in [0, x_size - 1] for y in range(y_size)]
 82
 83        # If shape is None, set the default shape
 84        if shape is None:
 85            self.shape = [(0, 0), (0, 1), (1, 0), (1, 1)]
 86        else:
 87            self.shape = shape
 88
 89        # If conn_data is None, set the default connection data
 90        if conn_data is None:
 91            sqrt_2 = 1.4142
 92            self.conn_data = [
 93                (-1, -1, sqrt_2),
 94                (-1, 0, 1),
 95                (-1, 1, sqrt_2),
 96                (0, -1, 1),
 97                (0, 1, 1),
 98                (1, -1, sqrt_2),
 99                (1, 0, 1),
100                (1, 1, sqrt_2),
101            ]
102        else:
103            self.conn_data = conn_data
104
105        self.graph = self.__create_graph__()
106        self.nodes = [self.__get_x_y__(idx) for idx in range(len(self.graph))]
107        self.graph_object = Graph(self.graph)
108
109    def __get_idx__(self, x: int, y: int):
110        """
111        Function:
112        - Get the index of a cell in a 2D grid given its x and y coordinates
113        - This does not have any error checking, so be careful with the inputs
114
115        Required Arguments:
116        - `x`
117            - Type: int
118            - What: The x coordinate of the cell
119        - `y`
120            - Type: int
121            - What: The y coordinate of the cell
122
123        Returns:
124
125        - `idx`
126            - Type: int
127            - What: The index of the cell in the grid
128        """
129        return x + y * self.x_size
130
131    def __get_x_y__(self, idx: int):
132        """
133        Function:
134        - Get the x and y coordinates of a cell in a 2D grid given its index
135        - This does not have any error checking so be careful with the inputs
136
137        Required Arguments:
138
139        - `idx`
140            - Type: int
141            - What: The index of the cell
142
143        Returns:
144        - `x`
145            - Type: int
146            - What: The x coordinate of the cell
147        - `y`
148            - Type: int
149            - What: The y coordinate of the cell
150        """
151        return idx % self.x_size, idx // self.x_size
152
153    def get_idx(self, x: int, y: int):
154        """
155        Function:
156        - Get the index of a cell in a 2D grid given its x and y coordinates
157
158        Required Arguments:
159        - `x`
160            - Type: int
161            - What: The x coordinate of the cell
162        - `y`
163            - Type: int
164            - What: The y coordinate of the cell
165
166        Returns:
167
168        - `idx`
169            - Type: int
170            - What: The index of the cell in the grid
171        """
172        assert x >= 0 and x < self.x_size, "x coordinate is out of bounds"
173        assert y >= 0 and y < self.y_size, "y coordinate is out of bounds"
174        return x + y * self.x_size
175
176    def get_x_y(self, idx: int):
177        """
178        Function:
179        - Get the x and y coordinates of a cell in a 2D grid given its index
180
181        Required Arguments:
182
183        - `idx`
184            - Type: int
185            - What: The index of the cell
186
187        Returns:
188        - `x`
189            - Type: int
190            - What: The x coordinate of the cell
191        - `y`
192            - Type: int
193            - What: The y coordinate of the cell
194        """
195        assert idx >= 0 and idx < len(self.graph), "idx is out of bounds"
196        return idx % self.x_size, idx // self.x_size
197
198    def __get_relative_shape_offsets__(
199        self,
200        shape: list[tuple[int | float, int | float]],
201        x_off: int,
202        y_off: int,
203    ):
204        """
205        Returns the list of (x, y) integer grid cells that the shape overlaps
206        as it moves from (0, 0) to (x_off, y_off).
207        """
208        # Get bounding box of the shape at start and end positions
209        xs = [coord[0] for coord in shape]
210        ys = [coord[1] for coord in shape]
211
212        # Calculate the 1d overlaps in x and y directions
213        x_cells = list(
214            range(
215                int(min(xs) + min(0, x_off)), int(ceil(max(xs) + max(0, x_off)))
216            )
217        )
218        y_cells = list(
219            range(
220                int(min(ys) + min(0, y_off)), int(ceil(max(ys) + max(0, y_off)))
221            )
222        )
223
224        # Combine to get 2D cell overlaps
225        result = set()
226        for x_key in x_cells:
227            for y_key in y_cells:
228                result.add((x_key, y_key))
229
230        # Remove untouched cells if moving diagonally
231        if x_off != 0 and y_off != 0:
232            slope = y_off / x_off
233
234            # Find min and max vertex given the slope
235            orthogonal = -1 / slope  # Compute orthogonal slope
236            # Define direction projections (a normalized direction vector, but without the linear algebra)
237            # Note: Technically normalized length is (1**2 + orthogonal**2)**.5, but we avoid the extra square for performance
238            length = (1 + orthogonal**2) ** 0.5
239            projections = [
240                x * 1.0 / length + y * orthogonal / length for x, y in shape
241            ]
242            # Return the min and max verticies
243            min_vertex = shape[
244                min(enumerate(projections), key=lambda x: x[1])[0]
245            ]
246            max_vertex = shape[
247                max(enumerate(projections), key=lambda x: x[1])[0]
248            ]
249            if slope > 0:
250                min_vertex, max_vertex = max_vertex, min_vertex
251
252            shape_min_intercept = min_vertex[1] - slope * min_vertex[0]
253            shape_max_intercept = max_vertex[1] - slope * max_vertex[0]
254
255            ltx_increment, gtx_increment = (1, 0) if slope < 0 else (0, 1)
256
257            remove_cells = []
258            for x_cell, y_cell in result:
259                cell_min_intercept = y_cell - (slope * (x_cell + gtx_increment))
260                cell_max_intercept = (y_cell + 1) - (
261                    slope * (x_cell + ltx_increment)
262                )
263
264                if not (
265                    cell_min_intercept < shape_max_intercept
266                    and shape_min_intercept < cell_max_intercept
267                ):
268                    remove_cells.append((x_cell, y_cell))
269            for key in remove_cells:
270                result.remove(key)
271        # Return the list of offsets
272        return list(result)
273
274    def __create_graph__(
275        self,
276    ):
277        """
278        Function:
279
280        - Create a graph from the grid specifications
281
282        Returns:
283
284        - `graph`
285            - Type: list
286            - What: The adjacency list representation of the graph
287        """
288        ####################
289        # Create a list of lists to hold all the possible connections in an easy to access/understand format
290        ####################
291
292        # Each cell in the list will be a dictionary of connections
293        # The keys will be the coordinates of the cell that can be moved to
294        # The values will be the distance to that cell
295        # This essentially creates a sparse matrix
296        graph_matrix = [
297            [{} for _ in range(self.x_size)] for _ in range(self.y_size)
298        ]
299        for y in range(self.y_size):
300            for x in range(self.x_size):
301                for x_off, y_off, dist in self.conn_data:
302                    if (
303                        0 <= x + x_off < self.x_size
304                        and 0 <= y + y_off < self.y_size
305                    ):
306                        graph_matrix[y][x][(x + x_off, y + y_off)] = dist
307
308        ####################
309        # Remove all connections that would not be possible based on the blocks and the shape
310        ####################
311
312        # Create a set of offsets that will be used to help remove connections relative to each blocked cell
313        delta_offsets = {
314            (x_delta, y_delta): self.__get_relative_shape_offsets__(
315                self.shape, x_delta, y_delta
316            )
317            for x_delta, y_delta, _ in self.conn_data
318        }
319
320        # Loop over all the blocks and remove all connections that would be not possible based on the blocks
321        for block in self.blocks:
322            # Clear out all connections leaving from the blocked cell
323            graph_matrix[block[1]][block[0]] = {}
324            # Clear out all connections that would be blocked by each movement given the blocked cell and the delta offsets
325            # Our delta offsets dictionary essentially tells us which relative cells would be blocked by the moving shape
326            for delta, offsets in delta_offsets.items():
327                for offset in offsets:
328                    x_cell = block[0] - offset[0]
329                    y_cell = block[1] - offset[1]
330                    if 0 <= x_cell < self.x_size and 0 <= y_cell < self.y_size:
331                        x_move_to = x_cell + delta[0]
332                        y_move_to = y_cell + delta[1]
333                        graph_matrix[y_cell][x_cell].pop(
334                            (x_move_to, y_move_to), None
335                        )
336
337        ####################
338        # Convert the graph matrix into the very efficient graph format as specified by the scgraph library
339        ####################
340        graph = []
341        for x_data in graph_matrix:
342            for connection_dict in x_data:
343                graph.append(
344                    {
345                        self.__get_idx__(x, y): dist
346                        for (x, y), dist in connection_dict.items()
347                    }
348                )
349
350        return graph
351
352    def euclidean_heuristic(self, origin_id: int, destination_id: int) -> float:
353        """
354        Function:
355
356        - Calculate the Euclidean distance between two nodes in the grid graph
357
358        Required Arguments:
359
360        - `origin_id`
361            - Type: int
362            - What: The id of the origin node
363        - `destination_id`
364            - Type: int
365            - What: The id of the destination node
366
367        Returns:
368
369        - `distance`
370            - Type: float
371            - What: The Euclidean distance between the two nodes
372        """
373        origin_location = self.nodes[origin_id]
374        destination_location = self.nodes[destination_id]
375        return (
376            (origin_location[0] - destination_location[0]) ** 2
377            + (origin_location[1] - destination_location[1]) ** 2
378        ) ** 0.5
379
380    def __get_closest_node_with_connections__(
381        self,
382        x: int | float,
383        y: int | float,
384    ):
385        """
386        Function:
387
388        - Get the closest node in the graph that has connections
389
390        Required Arguments:
391
392        - `x`
393            - Type: int | float
394            - What: The x coordinate of the node
395        - `y`
396            - Type: int | float
397            - What: The y coordinate of the node
398
399        Returns:
400
401        - `closest_node_id`
402            - Type: int
403            - What: The id of the closest node with connections
404        - `closest_distance`
405            - Type: float
406            - What: The distance to the closest node with connections
407        """
408        x = int(x) if int(x) == x else x
409        y = int(y) if int(y) == y else y
410        if isinstance(x, int) and isinstance(y, int):
411            return self.get_idx(x, y), 0.0
412        closest_node_id = None
413        closest_distance = float("inf")
414        for x_off, y_off in [(0, 0), (1, 0), (0, 1), (1, 1)]:
415            node_x = int(x) + x_off
416            node_y = int(y) + y_off
417            try:
418                node_id = self.get_idx(node_x, node_y)
419            except AssertionError:
420                continue
421            if self.graph[node_id] == {}:
422                continue
423            distance = ((node_x - x) ** 2 + (node_y - y) ** 2) ** 0.5
424            if distance < closest_distance:
425                closest_distance = distance
426                closest_node_id = node_id
427        if closest_node_id is None:
428            raise ValueError(
429                "No valid adjacent node with connections found for the given coordinates"
430            )
431        return closest_node_id, closest_distance
432
433    def format_coordinate(self, cooinate_dict, output_coorinate_path):
434        if output_coorinate_path == "list_of_tuples":
435            return (cooinate_dict["x"], cooinate_dict["y"])
436        elif output_coorinate_path == "list_of_lists":
437            return [cooinate_dict["x"], cooinate_dict["y"]]
438        elif output_coordinate_path == "list_of_dicts":
439            return {"x": cooinate_dict["x"], "y": cooinate_dict["y"]}
440        else:
441            raise ValueError("Invalid output_coordinate_path format")
442
443    def get_shortest_path(
444        self,
445        origin_node: (
446            dict[Literal["x", "y"], int | float]
447            | tuple[int | float, int | float]
448            | list[int | float]
449        ),
450        destination_node: (
451            dict[Literal["x", "y"], int | float]
452            | tuple[int | float, int | float]
453            | list[int | float]
454        ),
455        output_coordinate_path: str = "list_of_dicts",
456        output_path: bool = False,
457        algorithm_fn: str = "dijkstra",
458        algorithm_kwargs: dict | None = None,
459        **kwargs,
460    ) -> dict:
461        """
462        Function:
463
464        - Identify the shortest path between two nodes in a sparse network graph
465        - If an off graph origin and/or destination node is provided, it will find the closest adjacent node with connections
466          as the origin and/or destination node
467            - This is done to increase the likelihood that the pathfinding algorithm can find a valid path
468            - If no valid adjacent node is found, an error will be raised
469
470        - Return a dictionary of various path information including:
471            - `path`: A list of graph ids in the order they are visited
472            - `coordinate_path`: A list of dicts (x, y) in the order they are visited
473            - `length`: The length of the path
474
475         Required Arguments:
476
477        - `origin_node`
478            - Type: dict of int | float
479            - What: A dictionary with the keys 'x' and 'y'
480            - Alternatively, a tuple or list of two values (x, y) can be used
481        - `destination_node`
482            - Type: dict of int | float
483            - What: A dictionary with the keys 'x' and 'y'
484            - Alternatively, a tuple or list of two values (x, y) can be used
485
486        Optional Arguments:
487
488        - `output_coordinate_path`
489            - Type: str
490            - What: The format of the output coordinate path
491            - Default: 'list_of_lists'
492            - Options:
493                - `list_of_tuples`: A list of tuples with the first value being x and the second being y
494                - 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
495                - 'list_of_lists': A list of lists with the first value being x and the second being y
496        - `output_path`
497            - Type: bool
498            - What: Whether to output the path as a list of graph idxs (mostly for debugging purposes)
499            - Default: False
500        - `algorithm_fn`
501            - Type: str | callable
502            - What: The algorithm to use for pathfinding
503            - Default: 'dijkstra'
504            - Options:
505                - 'dijkstra': Standard Dijkstra's algorithm (default)
506                - Any method name from the Graph class (e.g. 'bellman_ford', 'a_star', 'bmssp', 'cached_shortest_path', etc.)
507                - Any user defined function that takes origin_id, destination_id, and **algorithm_kwargs
508        - `algorithm_kwargs`
509            - Type: dict
510            - What: Additional keyword arguments to pass to the algorithm function
511            - Default: {}
512        - `**kwargs`
513            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
514        """
515        if isinstance(origin_node, (tuple, list)):
516            origin_node = {"x": origin_node[0], "y": origin_node[1]}
517        if isinstance(destination_node, (tuple, list)):
518            destination_node = {
519                "x": destination_node[0],
520                "y": destination_node[1],
521            }
522        if algorithm_kwargs is None:
523            algorithm_kwargs = {}
524        if callable(algorithm_fn):
525            algorithm_kwargs["graph"] = self.graph
526        elif isinstance(algorithm_fn, str) and hasattr(
527            self.graph_object, algorithm_fn
528        ):
529            algorithm_fn = getattr(self.graph_object, algorithm_fn)
530        else:
531            raise ValueError("algorithm_fn must be a string or callable")
532
533        origin_id, origin_distance = self.__get_closest_node_with_connections__(
534            **origin_node
535        )
536        destination_id, destination_distance = (
537            self.__get_closest_node_with_connections__(**destination_node)
538        )
539
540        if self.graph[origin_id] == {}:
541            raise ValueError(
542                "Origin node is not connected to any other nodes. This is likely caused by the origin node not being possible given a blocked cell or nearby blocked cell"
543            )
544        if self.graph[destination_id] == {}:
545            raise ValueError(
546                "Destination node is not connected to any other nodes. This is likely caused by the destination node not being possible given a blocked cell or nearby blocked cell"
547            )
548        output = algorithm_fn(
549            origin_id=origin_id,
550            destination_id=destination_id,
551            **algorithm_kwargs,
552        )
553        output["coordinate_path"] = self.__get_coordinate_path__(
554            output["path"], output_coordinate_path
555        )
556        if origin_distance > 0:
557            output["coordinate_path"] = [
558                self.format_coordinate(origin_node, output_coordinate_path)
559            ] + output["coordinate_path"]
560            output["path"] = [-1] + output["path"]
561            output["length"] += origin_distance
562        if destination_distance > 0:
563            output["coordinate_path"].append(
564                self.format_coordinate(destination_node, output_coordinate_path)
565            )
566            output["path"].append(-1)
567            output["length"] += destination_distance
568        if not output_path:
569            del output["path"]
570        return output
571
572    def __get_coordinate_path__(
573        self, path: list[int], output_coordinate_path: str = "list_of_dicts"
574    ) -> list[dict[str, int]]:
575        """
576        Function:
577
578        - Return a list of node dictionaries (lat + long) in the order they are visited
579
580        Required Arguments:
581
582        - `path`
583            - Type: list
584            - What: A list of node ids in the order they are visited
585
586        Optional Arguments:
587
588        - `output_coordinate_path`
589            - Type: str
590            - What: The format of the output coordinate path
591            - Default: 'list_of_dicts'
592            - Options:
593                - 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
594                - 'list_of_lists': A list of lists with the first value being x and the second being y
595                - `list_of_tuples`: A list of tuples with the first value being x and the second being y
596
597        Returns:
598
599        - `coordinate_path`
600            - Type: list
601            - What: A list of dictionaries with keys 'x' and 'y' in the order they are visited
602        """
603        output = [self.__get_x_y__(idx) for idx in path]
604        if output_coordinate_path == "list_of_tuples":
605            return output
606        elif output_coordinate_path == "list_of_lists":
607            return [[x, y] for x, y in output]
608        elif output_coordinate_path == "list_of_dicts":
609            return [{"x": x, "y": y} for x, y in output]
610        else:
611            raise ValueError(
612                "output_coordinate_path must be 'list_of_dicts' or 'list_of_lists' or 'list_of_tuples'"
613            )
614
615    def export_object(
616        self, filename: str = "", include_blocks: bool = False
617    ) -> dict:
618        """
619        Function:
620
621        - Export the graph as a list of dictionaries
622
623        Arguments:
624
625        - `filename`
626            - Type: str
627            - What: The name of the file to export the graph to.
628            - An extension of .gridgraph will be added to the file name if not already present
629
630        Optional Arguments:
631
632        - `include_blocks`
633            - Type: bool
634            - What: Whether to include the blocks in the export
635            - Default: False
636            - Note: This is not needed as the graph is already created
637            - Note: You can include blocks in the export if you need them for some reason
638                - This will be set as the blocks attribute in the imported object
639                - This will increase the size of the export file
640        """
641        if filename == "":
642            raise ValueError("filename must be specified to export the graph")
643        filename = (
644            filename
645            if filename.endswith(".gridgraph")
646            else filename + ".gridgraph"
647        )
648        try:
649            import pickle, zlib, os
650        except ImportError:
651            raise ImportError(
652                "os, pickle and zlib are required to export the graph"
653            )
654        export_data = {
655            "graph_attributes": {
656                "graph": self.graph,
657                "nodes": self.nodes,
658                "x_size": self.x_size,
659                "y_size": self.y_size,
660                "shape": self.shape,
661                "add_exterior_walls": self.add_exterior_walls,
662                "conn_data": self.conn_data,
663            },
664            "graph_cache": self.graph_object.get_cache(),
665            "export_version": 1,
666        }
667        if include_blocks:
668            export_data["graph_attributes"]["blocks"] = self.blocks
669
670        if not os.path.exists(os.path.dirname(filename)):
671            os.makedirs(os.path.dirname(filename))
672        with open(filename, "wb") as f:
673            f.write(zlib.compress(pickle.dumps(export_data)))
674
675    @staticmethod
676    def import_object(filename: str = "") -> None:
677        """
678        Function:
679
680        - A staticmethod to import the graph from a file
681
682        Arguments:
683
684        - `filename`
685            - Type: str
686            - What: The name of the file to import the graph from.
687            - An extension of .gridgraph will be added to the file name if not already present
688
689        Returns:
690
691        - `GridGraph`
692            - Type: GridGraph
693            - What: The imported graph object
694        """
695        if filename == "":
696            raise ValueError("filename must be specified to import the graph")
697        filename = (
698            filename
699            if filename.endswith(".gridgraph")
700            else filename + ".gridgraph"
701        )
702        try:
703            import pickle, zlib
704        except ImportError:
705            raise ImportError(
706                "pickle and zlib are required to import the graph"
707            )
708        with open(filename, "rb") as f:
709            import_data = pickle.loads(zlib.decompress(f.read()))
710
711        # Check the version of the export
712        if import_data["export_version"] != 1:
713            raise ValueError(
714                f"Incompatible export version {import_data['export_version']}. The current version is 1"
715            )
716        # Create a new basic GridGraph object and overwrite the attributes with the imported data
717        # This is done as the init method calls the __create_graph__ method which is not needed here
718        GridGraph_object = GridGraph(
719            x_size=1, y_size=1, blocks=[], add_exterior_walls=False
720        )
721        for key, value in import_data["graph_attributes"].items():
722            GridGraph_object.__setattr__(key, value)
723
724        GridGraph_object.graph_object = Graph(GridGraph_object.graph)
725        GridGraph_object.graph_object.set_cache(import_data["graph_cache"])
726        return GridGraph_object
GridGraph( x_size: int, y_size: int, blocks: list[tuple[int, int]], shape: list[tuple[int | float, int | float]] | None = None, add_exterior_walls: bool = True, conn_data: list[tuple[int, int, float]] | None = None)
  8    def __init__(
  9        self,
 10        x_size: int,
 11        y_size: int,
 12        blocks: list[tuple[int, int]],
 13        shape: list[tuple[int | float, int | float]] | None = None,
 14        add_exterior_walls: bool = True,
 15        conn_data: list[tuple[int, int, float]] | None = None,
 16    ) -> None:
 17        """
 18        Initializes a GridGraph object.
 19
 20        Required Arguments:
 21
 22        - `x_size`
 23            - Type: int
 24            - What: The size of the grid in the x direction
 25        - `y_size`
 26            - Type: int
 27            - What: The size of the grid in the y direction
 28        - `blocks`
 29            - Type: list of tuples
 30            - What: A list of tuples representing the coordinates of the blocked cells
 31
 32        Optional Arguments:
 33
 34        - `shape`
 35            - Type: list of tuples
 36            - What: A list of tuples representing the shape of the moving object relative to the object location in the grid
 37            - Default: [(0, 0), (0, 1), (1, 0), (1, 1)]
 38            - Example: [(0, 0), (0, 1), (1, 0), (1, 1)]
 39                - Note: This would form a square of size 1x1
 40                - Assuming the grid location is at (0,0) the square takes up the grid space between (0, 0) and (1, 1)
 41                - Assumint the grid location is at (1,1) the square takes up the grid space between (1, 1) and (2, 2) with the same shape
 42            - Note: This shape is used to determine which connections are allowed when passing through the grid.
 43                - For example
 44                    - The shape is a square of size 1x1
 45                    - The location of the square is (0, 0)
 46                    - There is a blocked cell at (0, 1)
 47                    - There is a potential connection to (1, 1), (0, 1), and (1, 0)
 48                    - The square can not move to (1, 1) as it would collide with the blocked cell at (0, 1)
 49                      - This is because the square would pass partly through the blocked cell on its way to (1, 1)
 50                      - To achieve the same result, it can move to (1, 0) and then up to (1, 1) taking extra time / distance
 51        - `add_exterior_walls`
 52            - Type: bool
 53            - What: Whether to add exterior walls to the grid
 54            - Default: True
 55        - `conn_data`
 56            - Type: list of tuples
 57            - What: A list of tuples representing movements allowed in the grid
 58                - Each tuple should contain three values: (x_offset, y_offset, distance)
 59                - x_offset: The x offset from the current cell
 60                - y_offset: The y offset from the current cell
 61                - distance: The distance to the connected cell
 62            - Default: [(0, 1, 1), (1, 0, 1), (0, -1, 1), (-1, 0, 1), (1, 1, sqrt(2)), (-1, -1, sqrt(2)), (1, -1, sqrt(2)), (-1, 1, sqrt(2))]
 63                - Note: If None, the default connection data will be used
 64                - Note: This includes 8 directions, 4 cardinal and 4 diagonal
 65                - Cardinal directions are 1 unit away, diagonal directions are sqrt(2) units away
 66        """
 67        # Validate the inputs
 68        assert x_size > 0, "x_size must be greater than 0"
 69        assert y_size > 0, "y_size must be greater than 0"
 70
 71        self.x_size = x_size
 72        self.y_size = y_size
 73        self.blocks = blocks
 74        self.add_exterior_walls = add_exterior_walls
 75
 76        # Update the blocks if add_exterior_walls is True
 77        # This is done by adding all the cells in the first and last row and column
 78        if add_exterior_walls:
 79            self.blocks += [
 80                (x, y) for x in range(x_size) for y in [0, y_size - 1]
 81            ] + [(x, y) for x in [0, x_size - 1] for y in range(y_size)]
 82
 83        # If shape is None, set the default shape
 84        if shape is None:
 85            self.shape = [(0, 0), (0, 1), (1, 0), (1, 1)]
 86        else:
 87            self.shape = shape
 88
 89        # If conn_data is None, set the default connection data
 90        if conn_data is None:
 91            sqrt_2 = 1.4142
 92            self.conn_data = [
 93                (-1, -1, sqrt_2),
 94                (-1, 0, 1),
 95                (-1, 1, sqrt_2),
 96                (0, -1, 1),
 97                (0, 1, 1),
 98                (1, -1, sqrt_2),
 99                (1, 0, 1),
100                (1, 1, sqrt_2),
101            ]
102        else:
103            self.conn_data = conn_data
104
105        self.graph = self.__create_graph__()
106        self.nodes = [self.__get_x_y__(idx) for idx in range(len(self.graph))]
107        self.graph_object = Graph(self.graph)

Initializes a GridGraph object.

Required Arguments:

  • x_size
    • Type: int
    • What: The size of the grid in the x direction
  • y_size
    • Type: int
    • What: The size of the grid in the y direction
  • blocks
    • Type: list of tuples
    • What: A list of tuples representing the coordinates of the blocked cells

Optional Arguments:

  • shape
    • Type: list of tuples
    • What: A list of tuples representing the shape of the moving object relative to the object location in the grid
    • Default: [(0, 0), (0, 1), (1, 0), (1, 1)]
    • Example: [(0, 0), (0, 1), (1, 0), (1, 1)]
      • Note: This would form a square of size 1x1
      • Assuming the grid location is at (0,0) the square takes up the grid space between (0, 0) and (1, 1)
      • Assumint the grid location is at (1,1) the square takes up the grid space between (1, 1) and (2, 2) with the same shape
    • Note: This shape is used to determine which connections are allowed when passing through the grid.
      • For example
        • The shape is a square of size 1x1
        • The location of the square is (0, 0)
        • There is a blocked cell at (0, 1)
        • There is a potential connection to (1, 1), (0, 1), and (1, 0)
        • The square can not move to (1, 1) as it would collide with the blocked cell at (0, 1)
          • This is because the square would pass partly through the blocked cell on its way to (1, 1)
          • To achieve the same result, it can move to (1, 0) and then up to (1, 1) taking extra time / distance
  • add_exterior_walls
    • Type: bool
    • What: Whether to add exterior walls to the grid
    • Default: True
  • conn_data
    • Type: list of tuples
    • What: A list of tuples representing movements allowed in the grid
      • Each tuple should contain three values: (x_offset, y_offset, distance)
      • x_offset: The x offset from the current cell
      • y_offset: The y offset from the current cell
      • distance: The distance to the connected cell
    • Default: [(0, 1, 1), (1, 0, 1), (0, -1, 1), (-1, 0, 1), (1, 1, sqrt(2)), (-1, -1, sqrt(2)), (1, -1, sqrt(2)), (-1, 1, sqrt(2))]
      • Note: If None, the default connection data will be used
      • Note: This includes 8 directions, 4 cardinal and 4 diagonal
      • Cardinal directions are 1 unit away, diagonal directions are sqrt(2) units away
x_size
y_size
blocks
add_exterior_walls
graph
nodes
graph_object
def get_idx(self, x: int, y: int):
153    def get_idx(self, x: int, y: int):
154        """
155        Function:
156        - Get the index of a cell in a 2D grid given its x and y coordinates
157
158        Required Arguments:
159        - `x`
160            - Type: int
161            - What: The x coordinate of the cell
162        - `y`
163            - Type: int
164            - What: The y coordinate of the cell
165
166        Returns:
167
168        - `idx`
169            - Type: int
170            - What: The index of the cell in the grid
171        """
172        assert x >= 0 and x < self.x_size, "x coordinate is out of bounds"
173        assert y >= 0 and y < self.y_size, "y coordinate is out of bounds"
174        return x + y * self.x_size

Function:

  • Get the index of a cell in a 2D grid given its x and y coordinates

Required Arguments:

  • x
    • Type: int
    • What: The x coordinate of the cell
  • y
    • Type: int
    • What: The y coordinate of the cell

Returns:

  • idx
    • Type: int
    • What: The index of the cell in the grid
def get_x_y(self, idx: int):
176    def get_x_y(self, idx: int):
177        """
178        Function:
179        - Get the x and y coordinates of a cell in a 2D grid given its index
180
181        Required Arguments:
182
183        - `idx`
184            - Type: int
185            - What: The index of the cell
186
187        Returns:
188        - `x`
189            - Type: int
190            - What: The x coordinate of the cell
191        - `y`
192            - Type: int
193            - What: The y coordinate of the cell
194        """
195        assert idx >= 0 and idx < len(self.graph), "idx is out of bounds"
196        return idx % self.x_size, idx // self.x_size

Function:

  • Get the x and y coordinates of a cell in a 2D grid given its index

Required Arguments:

  • idx
    • Type: int
    • What: The index of the cell

Returns:

  • x
    • Type: int
    • What: The x coordinate of the cell
  • y
    • Type: int
    • What: The y coordinate of the cell
def euclidean_heuristic(self, origin_id: int, destination_id: int) -> float:
352    def euclidean_heuristic(self, origin_id: int, destination_id: int) -> float:
353        """
354        Function:
355
356        - Calculate the Euclidean distance between two nodes in the grid graph
357
358        Required Arguments:
359
360        - `origin_id`
361            - Type: int
362            - What: The id of the origin node
363        - `destination_id`
364            - Type: int
365            - What: The id of the destination node
366
367        Returns:
368
369        - `distance`
370            - Type: float
371            - What: The Euclidean distance between the two nodes
372        """
373        origin_location = self.nodes[origin_id]
374        destination_location = self.nodes[destination_id]
375        return (
376            (origin_location[0] - destination_location[0]) ** 2
377            + (origin_location[1] - destination_location[1]) ** 2
378        ) ** 0.5

Function:

  • Calculate the Euclidean distance between two nodes in the grid graph

Required Arguments:

  • origin_id
    • Type: int
    • What: The id of the origin node
  • destination_id
    • Type: int
    • What: The id of the destination node

Returns:

  • distance
    • Type: float
    • What: The Euclidean distance between the two nodes
def format_coordinate(self, cooinate_dict, output_coorinate_path):
433    def format_coordinate(self, cooinate_dict, output_coorinate_path):
434        if output_coorinate_path == "list_of_tuples":
435            return (cooinate_dict["x"], cooinate_dict["y"])
436        elif output_coorinate_path == "list_of_lists":
437            return [cooinate_dict["x"], cooinate_dict["y"]]
438        elif output_coordinate_path == "list_of_dicts":
439            return {"x": cooinate_dict["x"], "y": cooinate_dict["y"]}
440        else:
441            raise ValueError("Invalid output_coordinate_path format")
def get_shortest_path( self, origin_node: dict[Literal['x', 'y'], int | float] | tuple[int | float, int | float] | list[int | float], destination_node: dict[Literal['x', 'y'], int | float] | tuple[int | float, int | float] | list[int | float], output_coordinate_path: str = 'list_of_dicts', output_path: bool = False, algorithm_fn: str = 'dijkstra', algorithm_kwargs: dict | None = None, **kwargs) -> dict:
443    def get_shortest_path(
444        self,
445        origin_node: (
446            dict[Literal["x", "y"], int | float]
447            | tuple[int | float, int | float]
448            | list[int | float]
449        ),
450        destination_node: (
451            dict[Literal["x", "y"], int | float]
452            | tuple[int | float, int | float]
453            | list[int | float]
454        ),
455        output_coordinate_path: str = "list_of_dicts",
456        output_path: bool = False,
457        algorithm_fn: str = "dijkstra",
458        algorithm_kwargs: dict | None = None,
459        **kwargs,
460    ) -> dict:
461        """
462        Function:
463
464        - Identify the shortest path between two nodes in a sparse network graph
465        - If an off graph origin and/or destination node is provided, it will find the closest adjacent node with connections
466          as the origin and/or destination node
467            - This is done to increase the likelihood that the pathfinding algorithm can find a valid path
468            - If no valid adjacent node is found, an error will be raised
469
470        - Return a dictionary of various path information including:
471            - `path`: A list of graph ids in the order they are visited
472            - `coordinate_path`: A list of dicts (x, y) in the order they are visited
473            - `length`: The length of the path
474
475         Required Arguments:
476
477        - `origin_node`
478            - Type: dict of int | float
479            - What: A dictionary with the keys 'x' and 'y'
480            - Alternatively, a tuple or list of two values (x, y) can be used
481        - `destination_node`
482            - Type: dict of int | float
483            - What: A dictionary with the keys 'x' and 'y'
484            - Alternatively, a tuple or list of two values (x, y) can be used
485
486        Optional Arguments:
487
488        - `output_coordinate_path`
489            - Type: str
490            - What: The format of the output coordinate path
491            - Default: 'list_of_lists'
492            - Options:
493                - `list_of_tuples`: A list of tuples with the first value being x and the second being y
494                - 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
495                - 'list_of_lists': A list of lists with the first value being x and the second being y
496        - `output_path`
497            - Type: bool
498            - What: Whether to output the path as a list of graph idxs (mostly for debugging purposes)
499            - Default: False
500        - `algorithm_fn`
501            - Type: str | callable
502            - What: The algorithm to use for pathfinding
503            - Default: 'dijkstra'
504            - Options:
505                - 'dijkstra': Standard Dijkstra's algorithm (default)
506                - Any method name from the Graph class (e.g. 'bellman_ford', 'a_star', 'bmssp', 'cached_shortest_path', etc.)
507                - Any user defined function that takes origin_id, destination_id, and **algorithm_kwargs
508        - `algorithm_kwargs`
509            - Type: dict
510            - What: Additional keyword arguments to pass to the algorithm function
511            - Default: {}
512        - `**kwargs`
513            - Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
514        """
515        if isinstance(origin_node, (tuple, list)):
516            origin_node = {"x": origin_node[0], "y": origin_node[1]}
517        if isinstance(destination_node, (tuple, list)):
518            destination_node = {
519                "x": destination_node[0],
520                "y": destination_node[1],
521            }
522        if algorithm_kwargs is None:
523            algorithm_kwargs = {}
524        if callable(algorithm_fn):
525            algorithm_kwargs["graph"] = self.graph
526        elif isinstance(algorithm_fn, str) and hasattr(
527            self.graph_object, algorithm_fn
528        ):
529            algorithm_fn = getattr(self.graph_object, algorithm_fn)
530        else:
531            raise ValueError("algorithm_fn must be a string or callable")
532
533        origin_id, origin_distance = self.__get_closest_node_with_connections__(
534            **origin_node
535        )
536        destination_id, destination_distance = (
537            self.__get_closest_node_with_connections__(**destination_node)
538        )
539
540        if self.graph[origin_id] == {}:
541            raise ValueError(
542                "Origin node is not connected to any other nodes. This is likely caused by the origin node not being possible given a blocked cell or nearby blocked cell"
543            )
544        if self.graph[destination_id] == {}:
545            raise ValueError(
546                "Destination node is not connected to any other nodes. This is likely caused by the destination node not being possible given a blocked cell or nearby blocked cell"
547            )
548        output = algorithm_fn(
549            origin_id=origin_id,
550            destination_id=destination_id,
551            **algorithm_kwargs,
552        )
553        output["coordinate_path"] = self.__get_coordinate_path__(
554            output["path"], output_coordinate_path
555        )
556        if origin_distance > 0:
557            output["coordinate_path"] = [
558                self.format_coordinate(origin_node, output_coordinate_path)
559            ] + output["coordinate_path"]
560            output["path"] = [-1] + output["path"]
561            output["length"] += origin_distance
562        if destination_distance > 0:
563            output["coordinate_path"].append(
564                self.format_coordinate(destination_node, output_coordinate_path)
565            )
566            output["path"].append(-1)
567            output["length"] += destination_distance
568        if not output_path:
569            del output["path"]
570        return output

Function:

  • Identify the shortest path between two nodes in a sparse network graph
  • If an off graph origin and/or destination node is provided, it will find the closest adjacent node with connections as the origin and/or destination node

    • This is done to increase the likelihood that the pathfinding algorithm can find a valid path
    • If no valid adjacent node is found, an error will be raised
  • Return a dictionary of various path information including:

    • path: A list of graph ids in the order they are visited
    • coordinate_path: A list of dicts (x, y) in the order they are visited
    • length: The length of the path

    Required Arguments:

  • origin_node

    • Type: dict of int | float
    • What: A dictionary with the keys 'x' and 'y'
    • Alternatively, a tuple or list of two values (x, y) can be used
  • destination_node
    • Type: dict of int | float
    • What: A dictionary with the keys 'x' and 'y'
    • Alternatively, a tuple or list of two values (x, y) can be used

Optional Arguments:

  • output_coordinate_path
    • Type: str
    • What: The format of the output coordinate path
    • Default: 'list_of_lists'
    • Options:
      • list_of_tuples: A list of tuples with the first value being x and the second being y
      • 'list_of_dicts': A list of dictionaries with keys 'x' and 'y'
      • 'list_of_lists': A list of lists with the first value being x and the second being y
  • output_path
    • Type: bool
    • What: Whether to output the path as a list of graph idxs (mostly for debugging purposes)
    • Default: False
  • algorithm_fn
    • Type: str | callable
    • What: The algorithm to use for pathfinding
    • Default: 'dijkstra'
    • Options:
      • 'dijkstra': Standard Dijkstra's algorithm (default)
      • Any method name from the Graph class (e.g. 'bellman_ford', 'a_star', 'bmssp', 'cached_shortest_path', etc.)
      • Any user defined function that takes origin_id, destination_id, and **algorithm_kwargs
  • algorithm_kwargs
    • Type: dict
    • What: Additional keyword arguments to pass to the algorithm function
    • Default: {}
  • **kwargs
    • Additional keyword arguments. These are included for forwards and backwards compatibility reasons, but are not currently used.
def export_object(self, filename: str = '', include_blocks: bool = False) -> dict:
615    def export_object(
616        self, filename: str = "", include_blocks: bool = False
617    ) -> dict:
618        """
619        Function:
620
621        - Export the graph as a list of dictionaries
622
623        Arguments:
624
625        - `filename`
626            - Type: str
627            - What: The name of the file to export the graph to.
628            - An extension of .gridgraph will be added to the file name if not already present
629
630        Optional Arguments:
631
632        - `include_blocks`
633            - Type: bool
634            - What: Whether to include the blocks in the export
635            - Default: False
636            - Note: This is not needed as the graph is already created
637            - Note: You can include blocks in the export if you need them for some reason
638                - This will be set as the blocks attribute in the imported object
639                - This will increase the size of the export file
640        """
641        if filename == "":
642            raise ValueError("filename must be specified to export the graph")
643        filename = (
644            filename
645            if filename.endswith(".gridgraph")
646            else filename + ".gridgraph"
647        )
648        try:
649            import pickle, zlib, os
650        except ImportError:
651            raise ImportError(
652                "os, pickle and zlib are required to export the graph"
653            )
654        export_data = {
655            "graph_attributes": {
656                "graph": self.graph,
657                "nodes": self.nodes,
658                "x_size": self.x_size,
659                "y_size": self.y_size,
660                "shape": self.shape,
661                "add_exterior_walls": self.add_exterior_walls,
662                "conn_data": self.conn_data,
663            },
664            "graph_cache": self.graph_object.get_cache(),
665            "export_version": 1,
666        }
667        if include_blocks:
668            export_data["graph_attributes"]["blocks"] = self.blocks
669
670        if not os.path.exists(os.path.dirname(filename)):
671            os.makedirs(os.path.dirname(filename))
672        with open(filename, "wb") as f:
673            f.write(zlib.compress(pickle.dumps(export_data)))

Function:

  • Export the graph as a list of dictionaries

Arguments:

  • filename
    • Type: str
    • What: The name of the file to export the graph to.
    • An extension of .gridgraph will be added to the file name if not already present

Optional Arguments:

  • include_blocks
    • Type: bool
    • What: Whether to include the blocks in the export
    • Default: False
    • Note: This is not needed as the graph is already created
    • Note: You can include blocks in the export if you need them for some reason
      • This will be set as the blocks attribute in the imported object
      • This will increase the size of the export file
@staticmethod
def import_object(filename: str = '') -> None:
675    @staticmethod
676    def import_object(filename: str = "") -> None:
677        """
678        Function:
679
680        - A staticmethod to import the graph from a file
681
682        Arguments:
683
684        - `filename`
685            - Type: str
686            - What: The name of the file to import the graph from.
687            - An extension of .gridgraph will be added to the file name if not already present
688
689        Returns:
690
691        - `GridGraph`
692            - Type: GridGraph
693            - What: The imported graph object
694        """
695        if filename == "":
696            raise ValueError("filename must be specified to import the graph")
697        filename = (
698            filename
699            if filename.endswith(".gridgraph")
700            else filename + ".gridgraph"
701        )
702        try:
703            import pickle, zlib
704        except ImportError:
705            raise ImportError(
706                "pickle and zlib are required to import the graph"
707            )
708        with open(filename, "rb") as f:
709            import_data = pickle.loads(zlib.decompress(f.read()))
710
711        # Check the version of the export
712        if import_data["export_version"] != 1:
713            raise ValueError(
714                f"Incompatible export version {import_data['export_version']}. The current version is 1"
715            )
716        # Create a new basic GridGraph object and overwrite the attributes with the imported data
717        # This is done as the init method calls the __create_graph__ method which is not needed here
718        GridGraph_object = GridGraph(
719            x_size=1, y_size=1, blocks=[], add_exterior_walls=False
720        )
721        for key, value in import_data["graph_attributes"].items():
722            GridGraph_object.__setattr__(key, value)
723
724        GridGraph_object.graph_object = Graph(GridGraph_object.graph)
725        GridGraph_object.graph_object.set_cache(import_data["graph_cache"])
726        return GridGraph_object

Function:

  • A staticmethod to import the graph from a file

Arguments:

  • filename
    • Type: str
    • What: The name of the file to import the graph from.
    • An extension of .gridgraph will be added to the file name if not already present

Returns:

  • GridGraph
    • Type: GridGraph
    • What: The imported graph object