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
- For example
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
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 visitedcoordinate_path: A list of dicts (x, y) in the order they are visitedlength: 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